Schema Validation
onValidate checks the values after the definition rules pass. schemaValidate() from @formhaus/core/schema turns any Standard Schema into an onValidate function.
import { schemaValidate } from '@formhaus/core/schema';
import { z } from 'zod';
export const onValidate = schemaValidate(
z.object({
email: z.email('Enter a valid email'),
players: z.array(z.object({ name: z.string().min(2, 'Use at least 2 letters') })).max(5, 'Up to 5 players'),
}),
);import { schemaValidate } from '@formhaus/core/schema';
import * as v from 'valibot';
export const onValidate = schemaValidate(
v.object({
email: v.pipe(v.string(), v.email('Enter a valid email')),
players: v.pipe(
v.array(v.object({ name: v.pipe(v.string(), v.minLength(2, 'Use at least 2 letters')) })),
v.maxLength(5, 'Up to 5 players'),
),
}),
);import { schemaValidate } from '@formhaus/core/schema';
import { type } from 'arktype';
export const onValidate = schemaValidate(
type({
email: 'string.email',
players: type({ name: 'string >= 2' }).array().atMostLength(5),
}),
);Pass it to the renderer or the engine:
<FormRenderer definition={definition} onValidate={onValidate} onSubmit={send} /><FormRenderer :definition="definition" :on-validate="onValidate" @submit="send" />const engine = new FormEngine(definition, {}, { onValidate });Which issues show
- Continue checks the visible fields of the current step, so one schema covers the whole form.
- Submit checks the visible fields of every step on the path, except skipped steps. An issue on another step opens that step.
- The first issue of a field wins.
- An issue without a path shows as a form error. It clears on the next Continue, Back or Submit.
- An issue deeper than a field, such as one item of a
multiselect, shows on that field. - An issue on any other key is dropped: fields of other steps, fields hidden by a condition, skipped steps and keys that no field has. A misspelled schema key never shows an error, so check the keys against the definition.
Empty answers
Keep required in the definition. Its rules run first, so an unanswered required field shows the definition message and the schema checks only answers.
A field nobody answered is missing from the values. A text field the user cleared is ''. Mark every key the definition does not require as optional in the schema, and pass emptyStrings: 'omit' so a cleared field passes .optional() like an untouched one:
import { schemaValidate } from '@formhaus/core/schema';
import { z } from 'zod';
export const onValidate = schemaValidate(z.object({ website: z.url('Enter a full address').optional() }), { emptyStrings: 'omit' });'omit' drops '' at the top level and in group items, only from what the schema sees. The default, 'keep', passes '' as it is.
Checks across fields
An object-level check, such as Zod .refine() or ArkType .narrow(), runs only when every key of the object passes. On Continue the keys of later steps are missing, so mark the keys optional and keep required in the definition:
import { schemaValidate } from '@formhaus/core/schema';
import { z } from 'zod';
export const onValidate = schemaValidate(
z
.object({
email: z.email('Enter a valid email').optional(),
password: z.string().min(8, 'Use at least 8 characters').optional(),
confirm: z.string().optional(),
})
.refine((values) => values.confirm === values.password, { message: 'Passwords do not match', path: ['confirm'] }),
);Zod .refine() with a when option and Valibot partialCheck() run a check on some keys while others fail.
Group items
Issue paths map to group item keys: ['players', 1, 'name'] shows on players.1.name. The schema validates the group as an array of objects, as in the examples above. An issue on an item field that a condition hides is dropped.
Async server check
An async refinement runs on Continue and Submit, never on each keystroke. The form stays busy until it resolves, and a result is dropped when the values or the step change in the meantime.
import { schemaValidate } from '@formhaus/core/schema';
import { z } from 'zod';
const checked = new Map<string, Promise<boolean>>();
function isFree(email: string): Promise<boolean> {
let free = checked.get(email);
if (!free) {
free = fetch(`/api/emails/${encodeURIComponent(email)}`)
.then((response) => {
if (response.status === 404) return true;
if (response.ok) return false;
throw new Error(`Email check failed: ${response.status}`);
})
.catch(() => {
checked.delete(email);
return true;
});
checked.set(email, free);
}
return free;
}
export const onValidate = schemaValidate(
z.object({
email: z.email('Enter a valid email').refine(isFree, 'This email is already registered'),
}),
);Zod runs the whole schema twice when a refinement is async: its Standard Schema validate tries a sync parse first, then parses again async. Valibot runs it once. The example caches the result per email, so each email is fetched once, also across Continue and Submit. A failed request lets the answer through and is tried again next time; the server checks the email again on submit.
On the server
Call the function with the values alone. Each issue maps to the top-level key of its path, so ['players', 1, 'name'] shows on players, and an issue without a path comes back under the empty key ''. Run it on the values that validateSubmission() returns, which hold only the fields on the active path:
import { validateSubmission } from '@formhaus/core/server';
app.post('/api/submit', async (req, res) => {
const { values, errors } = validateSubmission(definition, req.body?.values, {
skippedSteps: req.body?.skippedSteps,
});
const allErrors = { ...(await onValidate(values)), ...errors };
if (Object.keys(allErrors).length > 0) return res.status(422).json({ errors: allErrors });
res.json({ values });
});With a context, only issues on the paths in fields and issues without a path count. fields: [] keeps only the issues without a path.
Values and messages
- The submit handler gets the form values, not the schema output. Transforms, coercion and defaults in the schema do not apply. Parse the values again in the handler when you need the output.
- Schema messages come from the library and stay in its language. Pass your own messages, or set the library locale:
z.config(z.locales.de())in Zod,@valibot/i18nin Valibot.
onValidate without a schema
onValidate gets the values and the field paths in scope. It returns errors keyed by field path, or null to pass. It may be async.
import type { ValidateFn } from '@formhaus/core';
export const onValidate: ValidateFn = (values, { fields }) => {
const players: { name?: string }[] = Array.isArray(values.players) ? values.players : [];
const names = players.map((player) => player.name?.trim().toLowerCase());
const errors: Record<string, string> = {};
names.forEach((name, index) => {
const path = `players.${index}.name`;
if (name && fields.includes(path) && names.indexOf(name) !== index) errors[path] = 'Two players have this name';
});
if (fields.includes('players') && players.length % 2 === 1) errors[''] = 'Teams need an even number of players';
return errors;
};type ValidateFn = (
values: Record<string, unknown>,
context: ValidateContext,
) => Record<string, string> | null | void | Promise<Record<string, string> | null | void>;
interface ValidateContext {
stepId?: string;
fields: string[];
submit: boolean;
}fieldslists the visible field paths in scope, with group items such asplayers.0.name.stepIdis the current step. Single-step forms have none.submitistrueon Submit andfalseon Continue.- A key outside
fields, or the empty key'', shows as a form error.
Continue runs the definition rules, then onStepValidate, then onValidate, then onBeforeStepChange. Submit runs the definition rules, then onValidate, then onBeforeSubmit and the submit handler. Back does not run it. Skip runs it only when it submits the form. stepValidating and submitting are true while it runs.
When onValidate throws or rejects, the step or the submit stops, as with onBeforeSubmit: next() and submit() reject, and renderers call onError or show the error at the form level when it is omitted.
ValidatorFn stays synchronous. Use it for checks of one value.