---
url: https://formhaus.dev/guide/schema-validation.md
description: >-
  Validate Formhaus forms with Zod, Valibot or ArkType through onValidate and
  schemaValidate: step scope, group items and async server checks.
---

# Schema Validation

`onValidate` checks the values after the definition rules pass. `schemaValidate()` from `@formhaus/core/schema` turns any [Standard Schema](https://standardschema.dev) into an `onValidate` function.

```ts \[ArkType]
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),
  }),
);

```

[Open in the playground](/play/schema#p=players)

Pass it to the renderer or the engine:

::: code-group

```tsx [React]
<FormRenderer definition={definition} onValidate={onValidate} onSubmit={send} />
```

```vue [Vue]
<FormRenderer :definition="definition" :on-validate="onValidate" @submit="send" />
```

```ts [Core]
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:

```ts
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' });

```

[Open in the playground](/play/schema#p=empty)

`'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:

```ts
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'] }),
);

```

[Open in the playground](/play/schema#p=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.

```ts
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'),
  }),
);

```

[Open in the playground](/play/schema#p=server-check)

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()`](/guide/server-validation) returns, which hold only the fields on the active path:

```ts
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/i18n`](https://valibot.dev/guides/internationalization/) in 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.

```ts
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;
};

```

[Open in the playground](/play/schema#p=on-validate)

```ts
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;
}
```

* `fields` lists the visible field paths in scope, with group items such as `players.0.name`.
* `stepId` is the current step. Single-step forms have none.
* `submit` is `true` on Submit and `false` on Continue.
* A key outside `fields`, or the empty key `''`, shows as a form error.

Continue runs the definition rules, then [`onStepValidate`](/guide/async-validation), 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`](/guide/validation#custom-validators) stays synchronous. Use it for checks of one value.
