easy-forms
Validation

Custom & async validation

Write your own validators, return named errors, and check values against a server.

A custom validator is a function that returns an error string (or null when valid). It can be sync or async.

type CustomValidator<TValue, TFormData> = (
	value: TValue,
	allValues: TFormData,
) => string | null | Promise<string | null>;

A single custom validator

{
	key: 'username',
	label: 'Username',
	control: 'text',
	validators: {
		required: true,
		custom: (value) =>
			/^[a-z0-9_]+$/.test(value) ? null : 'Lowercase letters, numbers, and underscores only',
	},
}

Cross-field validation

Custom validators receive all form values, so confirming a password is easy:

{
	key: 'confirmPassword',
	label: 'Confirm password',
	control: 'text',
	inputType: 'password',
	validators: {
		custom: (value, all) => (value === all.password ? null : 'Passwords do not match'),
	},
}

Named validators (multiple errors)

Pass a record to run several named checks. Each result is tracked under its key in the field's errors map.

validators: {
	custom: {
		hasUpper: (v) => (/[A-Z]/.test(v) ? null : 'Needs an uppercase letter'),
		hasNumber: (v) => (/[0-9]/.test(v) ? null : 'Needs a number'),
	},
}

Async validators

Return a promise — ideal for uniqueness checks. The pipeline is token-guarded: if the value changes while an async check is in flight, the stale result is discarded.

{
	key: 'email',
	label: 'Email',
	control: 'email',
	validators: {
		email: true,
		custom: async (value) => {
			const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`);
			const { taken } = await res.json();
			return taken ? 'That email is already registered' : null;
		},
	},
}

Async (and all client) validation is for UX. Always re-validate on the server — see Security.