On this pageDefining a Field
Field Validation
Foldkit models field validation as data in your Model, not scattered logic across event handlers. Each field is a four-state discriminated union: NotValidated, Validating, Valid, and Invalid. This makes it impossible to render a success indicator while an error exists, or show a spinner when validation is already complete.
makeRules takes an options object and returns a Rules bundle. Field is the four-state schema you put in your Model.
import { Schema as S } from 'effect'
import { Field, email, makeRules, minLength } from 'foldkit/fieldValidation'
// Optional: no `required` option. The rule applies when the user fills it in.
const usernameRules = makeRules({
rules: [minLength(3, 'Must be at least 3 characters')],
})
// Required: empty values become `Invalid` with the given message.
const emailRules = makeRules({
required: 'Email is required',
rules: [email('Please enter a valid email address')],
})
const Model = S.Struct({
username: Field,
email: Field,
})
type Model = typeof Model.TypeThe four states: NotValidated for fields the user hasn’t interacted with yet, Validating for async checks in flight, Valid when all rules pass, and Invalid when one or more rules fail. Every state carries the current value, and Invalid additionally carries an errors array.
Each entry in the rules array is a Rule: a [predicate, errorMessage] tuple. Error messages can be static strings or functions that receive the invalid value. Foldkit ships built-in rules for common cases; see Custom Rules to write your own.
Operations are free module functions that take a Rules bundle as their first argument. Rules itself has no methods; the sections below introduce each operation.
To construct a state directly (e.g. initial Model values, async Command results), use the module-level constructors: NotValidated, Validating, Valid, Invalid.
A Rules bundle is just data, so build it from model state via a plain function.
import { makeRules, maxLength, validate } from 'foldkit/fieldValidation'
// A function that builds the bundle from whatever state it depends on.
const companyNameRules = (accountType: 'Personal' | 'Business') =>
makeRules({
...(accountType === 'Business' && {
required: 'Required for business accounts',
}),
rules: [maxLength(100)],
})
const validateCompanyName = (
accountType: 'Personal' | 'Business',
value: string,
) => validate(companyNameRules(accountType))(value)Call validate(rules)(value) to validate a value against a bundle of rules. It returns one of the four Field variants, failing fast at the first rule that fails. Use it in your update function with evo to set the field state.
import { Match as M } from 'effect'
import { validate } from 'foldkit/fieldValidation'
import { evo } from 'foldkit/struct'
const validateUsername = validate(usernameRules)
const update = (model: Model, message: Message) =>
M.value(message).pipe(
M.tagsExhaustive({
ChangedUsername: ({ value }) => [
evo(model, {
username: () => validateUsername(value),
}),
[],
],
}),
)Use validateAll(rules) when you want to collect every failing rule into the errors array rather than stopping at the first failure.
Match exhaustively on the four tags to derive border colors, status indicators, and error messages. For form-level submit gates, use allValid to fold across every field’s state and rules; for a single field, use isValid(rules)(state). If the rules are required, only Valid passes; if optional, NotValidated also passes.
import { Array, Match as M } from 'effect'
import { type Field, allValid } from 'foldkit/fieldValidation'
const borderClass = (field: Field) =>
M.value(field).pipe(
M.tagsExhaustive({
NotValidated: () => 'border-gray-300',
Validating: () => 'border-accent-300',
Valid: () => 'border-accent-500',
Invalid: () => 'border-red-500',
}),
)
// Branching views are wrapped in `keyed` so snabbdom patches the right tree
// when the tag flips.
const statusIndicator = (field: Field) =>
keyed('span')(
field._tag,
[],
[
M.value(field).pipe(
M.tagsExhaustive({
NotValidated: () => empty,
Validating: () => span([], ['Checking...']),
Valid: () => span([], ['✓']),
Invalid: ({ errors }) => div([], [Array.headNonEmpty(errors)]),
}),
),
],
)
// `allValid` walks each (state, rules) pair; required rules demand `Valid`,
// optional rules also accept `NotValidated`.
const isFormValid = (model: Model): boolean =>
allValid([
[model.username, usernameRules],
[model.email, emailRules],
])Because Field is a discriminated union, the exhaustive match ensures you handle every state.
For server-side checks like “Is this email taken?”, use the Validating state as a bridge: run sync validate first, then transition to Validating, fire a Command, and handle the result message.
import { Effect, Match as M, Number } from 'effect'
import { Command } from 'foldkit'
import { Invalid, Valid, Validating, validate } from 'foldkit/fieldValidation'
import { evo } from 'foldkit/struct'
const validateEmail = validate(emailRules)
const CheckEmailAvailable = Command.define(
'CheckEmailAvailable',
ValidatedEmail,
)
const checkEmailAvailable = (email: string, validationId: number) =>
CheckEmailAvailable(
Effect.gen(function* () {
const isAvailable = yield* apiCheckEmail(email)
return ValidatedEmail({
validationId,
field: isAvailable
? Valid({ value: email })
: Invalid({
value: email,
errors: ['This email is already taken'],
}),
})
}),
)
const update = (model: Model, message: Message) =>
M.value(message).pipe(
M.tagsExhaustive({
ChangedEmail: ({ value }) => {
const syncResult = validateEmail(value)
const validationId = Number.increment(model.emailValidationId)
return M.value(syncResult).pipe(
M.tag('Valid', () => [
evo(model, {
email: () => Validating({ value }),
emailValidationId: () => validationId,
}),
[checkEmailAvailable(value, validationId)],
]),
M.orElse(() => [
evo(model, {
email: () => syncResult,
emailValidationId: () => validationId,
}),
[],
]),
)
},
ValidatedEmail: ({ validationId, field }) => {
if (validationId === model.emailValidationId) {
return [evo(model, { email: () => field }), []]
} else {
return [model, []]
}
},
}),
)The validationId pattern prevents race conditions. Each keystroke increments the ID, and the result handler only applies if the ID still matches. Responses from superseded requests are silently discarded.
A Rule is a [predicate, errorMessage] tuple. Write your own by pairing any predicate with an error message (a static string, or a function that receives the value).
import type { Rule } from 'foldkit/fieldValidation'
const noConsecutiveSpaces: Rule = [
value => !/ /.test(value),
'Cannot contain consecutive spaces',
]
const hasUppercase: Rule = [
value => /[A-Z]/.test(value),
'Must contain at least one uppercase letter',
]
// Messages can be functions that receive the failing value:
const noTrailingWhitespace: Rule = [
value => value === value.trimEnd(),
value => `Remove the trailing whitespace from "${value}"`,
]Custom rules compose with built-in ones in the same rules array.
A Rule only sees a single value. For checks that compare fields against each other (like “confirm password must match password”), handle the logic directly in your update function where you have access to the full model.
import { Match as M } from 'effect'
import {
type Field,
Invalid,
Valid,
makeRules,
minLength,
validate,
} from 'foldkit/fieldValidation'
import { evo } from 'foldkit/struct'
const passwordRules = makeRules({
required: 'Password is required',
rules: [minLength(8, 'Must be at least 8 characters')],
})
const validatePassword = validate(passwordRules)
const validateConfirmPassword = (
password: string,
confirmPassword: string,
): Field =>
M.value(validatePassword(confirmPassword)).pipe(
M.tag('Valid', () =>
confirmPassword === password
? Valid({ value: confirmPassword })
: Invalid({
value: confirmPassword,
errors: ['Passwords must match'],
}),
),
M.orElse(invalidResult => invalidResult),
)
const update = (model: Model, message: Message) =>
M.value(message).pipe(
M.tagsExhaustive({
ChangedPassword: ({ value }) => [
evo(model, {
password: () => validatePassword(value),
confirmPassword: confirmPassword =>
M.value(confirmPassword).pipe(
M.tag('NotValidated', () => confirmPassword),
M.orElse(() =>
validateConfirmPassword(value, confirmPassword.value),
),
),
}),
[],
],
ChangedConfirmPassword: ({ value }) => [
evo(model, {
confirmPassword: () =>
validateConfirmPassword(model.password.value, value),
}),
[],
],
}),
)Keep cross-field logic in update only when the check genuinely needs more than one value. Anything expressible as [predicate, errorMessage] over a single value fits better as a custom rule.
Required-ness is not a rule. It’s a makeRules option: pass required: message to make the field required, omit it for an optional field.
| Rule | Description |
|---|---|
minLength(min, message?) | Minimum character count |
maxLength(max, message?) | Maximum character count |
pattern(regex, message?) | Matches a regular expression |
email(message?) | Valid email format |
url(options?) | Valid URL format |
startsWith(prefix, message?) | Begins with a prefix |
endsWith(suffix, message?) | Ends with a suffix |
includes(substring, message?) | Contains a substring |
equals(expected, message?) | Exact string match |
oneOf(values, message?) | Value is in a set of allowed strings |
See the full API reference for details on every export. For a complete working example with sync validation, async server checks, and form submission gating, see the Form example. For sync-only validation with OutMessage context, see the Auth example.