Skip to content

Validation

Two different things are called validation here, and they run at different times against different subjects. One checks the flow; the other checks the data.

A flow that arrives as JSON has been type-checked by nothing. validateFlow reads it and returns every problem it can find without running it:

import { validateFlow, assertFlow } from '@wizzard-packages/core/validate-flow';
// `signup` is the definition and `registry` the resolvers it names - the two
// things `createWizard` would be handed.
const problems = validateFlow(signup, registry);
// [{ code: 'target-unknown-step', path: 'steps.payment.on.next', message, fix, url }]

Each problem is { code, path, message, fix, url }: a stable code to switch on, where the problem is, and the same message a thrown error carries - what went wrong, why, the fix, and the page for the code. fix is the third sentence on its own, for a list that shows it apart.

// {
// code: 'target-unknown-step',
// path: 'steps.payment.on.next',
// message: '[wizzard] unknown target "confrm". A transition leads to a key of steps, or to @end
// from on.next. Correct the id, or add the step it names. https://zizzx.github.io/wizzard-packages/errors/target-unknown-step',
// fix: 'Correct the id, or add the step it names',
// url: 'https://zizzx.github.io/wizzard-packages/errors/target-unknown-step',
// }

It catches what a type cannot: a target naming a step that does not exist, a $ref naming a resolver the registry does not hold, an expression object whose key is not an operator, an order entry with no matching step, a step carrying both when and on.next where only one of them will be honoured.

It reads a $ref as a resolver and a $get as a path only where the engine evaluates them: a step’s expressions, its load and its validate. A step’s ui, the args of a $ref and the fields beside a flow’s steps, such as schema and meta, are your data and are handed on untouched, so a JSON Schema’s $ref there is not reported. In those, only a function or a cycle is a problem, because either one stops the flow from being stored or sent.

A group whose flow is an inline definition is checked like the root - its order and each of its steps - and its transitions are read against its own steps: inside a sub-flow, on.next names a step of that sub-flow. A sub-flow named by a string is a definition validateFlow was not handed, so it is checked where it is defined.

validateFlow answers in problems, never by throwing. A flow built in code can carry a getter that throws when it is read, and that arrives as a last problem, flow-unreadable, with whatever was found before it.

assertFlow is the same check that throws instead of returning, with every problem in the message of one flow-invalid error. Use it where a definition crossing a boundary should stop the program - a backend response in development, a fixture in a test - and use validateFlow where the problems are something to render.

This entry point is deliberately separate from @wizzard-packages/core. Checking a definition is development work; shipping the checker to every user who runs the flow is not.

The runtime kind is a step’s validate, a { $ref } naming a resolver:

steps: {
details: { label: 'Details', validate: { $ref: 'needsEmail' } },
}

The resolver is a function in the registry passed to createWizard. It receives the scope, and answers with the problems it found:

import type { AsyncRegistry } from '@wizzard-packages/core';
/**
* The code the flow names. A validator answers with the messages it found,
* keyed by field, or `null` when the step may be left.
*
* Not a boolean: `false` carries no message to show and does not stop the
* move. Only an object with at least one key refuses it.
*/
export const registry: AsyncRegistry = {
needsEmail: (_args, { data }) => {
const details = data['details'] as { email?: string } | undefined;
return details?.email ? null : { email: 'Enter an email address.' };
},
};

That answer is the whole contract. null, or an object with no keys, means the step may be left. An object with at least one key refuses the move, and each key is a field with its message - that is what lets a form put the message beside its input. A validator may be async and return a promise of either. A boolean is not an answer: true and false both let the step be left, so a predicate written for when and reused here guards nothing.

The engine runs it before every move away from the step except back(): next(), and go() wherever it leads, unless the move is passed validate: false. If it refuses, the move returns { ok: false, reason: 'invalid', by, errors } and the flow stays where it is.

You can also run it directly, and set messages yourself:

// `wizard` is the engine `createWizard` returns, or `useWizard()` in a component.
await wizard.validate(); // the current step
await wizard.validate('details'); // a named one
wizard.setErrors('details', { email: 'Enter a work address' });
wizard.setErrors('details', null); // clear

validate resolves to a boolean. The messages it produced live in the wizard’s state and are read through useErrors(), so a field component does not need the result of the call that created them.

Messages stay until they are replaced, the step passes a check, or reset() runs. back() keeps them, so a step that refused still says why when it is reached again. The exception is a repeat group: every item shares its steps’ ids, so a move that leaves an item drops the messages of that item’s steps - what the second passenger was refused for is not shown on the first one after Back. Groups has the rest of what a repeat changes.

There is no validateAll(). The engine checks a step as it is left, so a whole-flow check is a question about steps nobody has reached yet, and its answer goes stale as soon as anything is typed. Checking several steps on purpose is a loop over validate(step), which also says which one failed. API behaviour collects that rule beside the others.

A validator is a resolver, so a schema library is one adapter away. @wizzard-packages/validate is that adapter - one for every library, not one per library, because Zod 3.24+, Zod 4, Valibot, ArkType, Effect and Yup 1.5+ all speak Standard Schema:

Terminal window
pnpm add @wizzard-packages/core@canary @wizzard-packages/validate@canary
import { createWizard } from '@wizzard-packages/core';
import { schema } from '@wizzard-packages/validate';
import { z } from 'zod';
const wizard = createWizard({
flow: {
id: 'booking',
order: ['trip', 'payment'],
steps: { trip: { validate: { $ref: 'tripRules' } }, payment: {} },
},
registry: {
tripRules: schema(z.object({ name: z.string().min(1), age: z.number().min(18) })),
},
});
await wizard.start(); // the first move, which validates nothing: no step has been left yet
wizard.set('age', 16);
console.log(await wizard.next());

With Zod 4 it prints the refusal: by names the step that refused (here trip), errors holds one message per field, and code and url name the page that explains it:

{
ok: false,
reason: 'invalid',
by: 'trip',
errors: {
name: 'Invalid input: expected string, received undefined',
age: 'Too small: expected number to be >=18'
},
code: 'nav-invalid',
url: 'https://zizzx.github.io/wizzard-packages/errors/nav-invalid'
}

Swapping the Zod schema for a Valibot, ArkType, Effect or Yup one changes nothing else. The package never bundles a schema library; the one you use is yours.

schema(s, opts?) returns an async resolver: (args, scope) => Promise<Record<string, string> | null>. Keys are dot-paths, values the first message reported for that path, and null means the value is good. An issue path of ['guests', 1, 'name'] becomes the key guests.1.name; an issue with no path - a cross-field refinement - lands on the empty key, which is an error about the value as a whole. Where two issues share a path the first wins, because schemas report in declaration order and letting a later refinement overwrite the first hides the obvious failure behind the subtle one.

opts.at says which value to validate, as a path into the same scope the flow’s expressions address - data, data.trip, ctx.user. It defaults to data, the whole form, because most schemas describe the fields of the step being left and object schemas ignore keys they do not mention. A path that is absent hands the schema undefined rather than throwing, so a schema that allows a missing value stays in charge of that decision.

// `tripSchema` describes the trip alone, so it is handed `data.trip` rather than the whole
// form; `booking` is a flow whose `trip` step says `validate: { $ref: 'tripRules' }`.
createWizard({ flow: booking, registry: { tripRules: schema(tripSchema, { at: 'data.trip' }) } });

issuesToErrors is the same flattening on its own, for a validator that runs a schema itself and wants the engine’s error shape.

FlowDefinition.validate schedules the whole thing:

{ validate: { on: 'change', debounceMs: 300 } }

on takes 'change', 'blur', 'next' or 'manual'. This is the field that shares a name with a step’s validate and means something else entirely: the flow-level one says when, the step-level one says what. A flow may set either without the other.