On this pageCreating 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 — making it impossible to render a success indicator while an error exists, or show a spinner when validation is already complete.
makeField takes a value schema and returns four constructors plus a Union schema. Use the union as a field type in your Model.
import { Schema as S } from 'effect'
import { makeField } from 'foldkit/fieldValidation'
const StringField = makeField(S.String)
type StringField = typeof StringField.Union.Type
const Model = S.Struct({
username: StringField.Union,
email: StringField.Union,
})
type Model = typeof Model.TypeThe four states represent the complete lifecycle of a field: 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.
Validation rules are [predicate, errorMessage] tuples. Foldkit ships built-in validators for common cases — compose them into an array for each field.
import { FieldValidation } from 'foldkit'
import type { Validation } from 'foldkit/fieldValidation'
const usernameValidations: ReadonlyArray<Validation<string>> = [
FieldValidation.required('Username is required'),
FieldValidation.minLength(3, 'Must be at least 3 characters'),
FieldValidation.maxLength(
20,
value => `Too long (${value.length}/20)`,
),
FieldValidation.pattern(
/^[a-zA-Z0-9_]+$/,
'Letters, numbers, and underscores only',
),
]
const emailValidations: ReadonlyArray<Validation<string>> = [
FieldValidation.required('Email is required'),
FieldValidation.email('Please enter a valid email address'),
]Each validator returns a Validation<T> tuple. The predicate returns true when the value is valid. The error message can be a static string or a function that receives the invalid value — pass a function when the message needs to include context like value => `Too long (${value.length}/20)`.
Call StringField.validate(rules) to create a validation function, then apply it to a value. It returns either a Valid or Invalid schema instance. It fails fast — stopping at the first failing rule. Use it in your update function with evo to set the field state.
import { Match as M, Schema as S } from 'effect'
import { makeField } from 'foldkit/fieldValidation'
import { evo } from 'foldkit/struct'
const StringField = makeField(S.String)
const validateUsername = StringField.validate(usernameValidations)
const validateUsernameAll = StringField.validateAll(
usernameValidations,
)
const update = (model: Model, message: Message) =>
M.value(message).pipe(
M.tagsExhaustive({
ChangedUsername: ({ value }) => [
evo(model, {
username: () => validateUsername(value),
}),
[],
],
}),
)Use validateAll instead 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. Gate form submission on all fields being Valid.
import { Array, Match as M } from 'effect'
const borderClass = (field: StringField) =>
M.value(field).pipe(
M.tagsExhaustive({
NotValidated: () => 'border-gray-300',
Validating: () => 'border-blue-300',
Valid: () => 'border-green-500',
Invalid: () => 'border-red-500',
}),
)
const statusIndicator = (field: StringField) =>
M.value(field).pipe(
M.tagsExhaustive({
NotValidated: () => empty,
Validating: () => span([], ['Checking...']),
Valid: () => span([], ['✓']),
Invalid: ({ errors }) => div([], [Array.headNonEmpty(errors)]),
}),
)
const isFormValid = (model: Model): boolean =>
Array.every(
[model.username, model.email],
field => field._tag === 'Valid',
)Because the field type is a discriminated union, the exhaustive match ensures you handle every state. If you add a new state in the future, the compiler will tell you every view that needs updating.
For server-side checks like "Is this email taken?", use the Validating state as a bridge: run sync validation first, then transition to Validating, fire a command, and handle the result message.
import { Effect, Match as M, Number, Schema as S } from 'effect'
import { Command } from 'foldkit/command'
import { makeField } from 'foldkit/fieldValidation'
import { evo } from 'foldkit/struct'
const StringField = makeField(S.String)
const validateEmail = StringField.validate(emailValidations)
const checkEmailAvailable = (
email: string,
validationId: number,
): Command<typeof ValidatedEmail> =>
Effect.gen(function* () {
const isAvailable = yield* apiCheckEmail(email)
return ValidatedEmail({
validationId,
field: isAvailable
? StringField.Valid({ value: email })
: StringField.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: () => StringField.Validating({ value }),
emailValidationId: () => validationId,
}),
[checkEmailAvailable(value, validationId)],
]),
M.orElse(() => [
evo(model, { email: () => syncResult }),
[],
]),
)
},
ValidatedEmail: ({ validationId, field }) => {
if (validationId !== model.emailValidationId) {
return [model, []]
}
return [evo(model, { email: () => field }), []]
},
}),
)The validationId pattern prevents race conditions. Each keystroke increments the ID, and the result handler only applies if the ID still matches. Stale responses from slow requests are silently discarded.
Sync First
Run sync validation first. Only fire async commands when the sync rules pass. This avoids unnecessary API calls for obviously invalid input.
A Validation<T> is a [Predicate<T>, ValidationMessage<T>] tuple. Write your own by pairing any predicate with an error message — either a static string or a function that receives the value.
import type { Validation } from 'foldkit/fieldValidation'
const noConsecutiveSpaces: Validation<string> = [
value => !/ /.test(value),
'Cannot contain consecutive spaces',
]
const divisibleBy = (divisor: number): Validation<number> => [
value => value % divisor === 0,
value => `${value} is not divisible by ${divisor}`,
]Custom validators compose with built-in ones in the same rules array — there's no registration step or special interface to implement.
A Validation<T> 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, Schema as S } from 'effect'
import { FieldValidation } from 'foldkit'
import { makeField } from 'foldkit/fieldValidation'
import { evo } from 'foldkit/struct'
const StringField = makeField(S.String)
const validatePassword = StringField.validate([
FieldValidation.required('Password is required'),
FieldValidation.minLength(8, 'Must be at least 8 characters'),
])
const validateConfirmPassword = (
password: string,
confirmPassword: string,
) =>
M.value(validatePassword(confirmPassword)).pipe(
M.tag('Valid', () =>
confirmPassword === password
? StringField.Valid({ value: confirmPassword })
: StringField.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),
}),
[],
],
}),
)This is the natural place for cross-field logic — the update function already has the model, and constructing Valid or Invalid directly is straightforward.
Foldkit ships validators for strings, numbers, and generic values.
String validators:
| Validator | Description |
|---|---|
required(message?) | Non-empty string |
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(message?) | 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 |
Number validators:
| Validator | Description |
|---|---|
min(num, message?) | Greater than or equal to |
max(num, message?) | Less than or equal to |
between(min, max, message?) | Within an inclusive range |
positive(message?) | Greater than zero |
nonNegative(message?) | Zero or greater |
integer(message?) | Whole number |
Generic validators:
| Validator | Description |
|---|---|
oneOf(values, message?) | Value is in a set of allowed strings |
Every validator accepts an optional custom error message. When omitted, a sensible default is used.
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.