Forms and validation
Form state, rules and schema validation as a package of its own: `@vitral/forms` has no dependencies and no framework in it, so the same values, errors and submit flow work under Vue, under another framework, or under none. Adapters for Zod, Yup, Valibot, Superstruct, ArkType and anything else that implements Standard Schema.
@vitral/forms is the form layer as a package of its own: values, touched and dirty state, errors, submission, field arrays and nested paths, with no dependencies and no framework in it. The Form component is a thin binding over it, so everything on this page is the same under Vue, under another framework, or under none.
Without a framework
The form is an object you subscribe to. It owns the values and the errors; you own the markup and decide what to do when the state changes. That is what makes it reusable: a React binding, a Svelte store or a hand-written page all sit on the same API.
import { createForm, rules } from '@vitral/forms';
const form = createForm({
initialValues: { email: '', password: '' },
rules: {
email: [rules.required(), rules.email()],
password: [rules.required(), rules.minLength(8)]
},
validateOn: 'blur',
onSubmit: (values) => api.signIn(values)
});
// The state is a plain object, and every change is published to whoever asks.
form.subscribe((state) => {
error.textContent = state.errors['email']?.[0] ?? '';
button.disabled = state.submitting;
});
input.addEventListener('input', () => form.setValue('email', input.value, { trigger: 'input' }));
input.addEventListener('blur', () => form.blur('email'));
element.addEventListener('submit', (event) => {
event.preventDefault();
form.submit();
});Rules
Rules are per-field checks, declared in one place. They run on the trigger the form is configured for — submit by default, then on every input once a field has been validated once, so an error clears as it is fixed. Their messages come from the locale, so a form in Portuguese says so without being told twice.
import { createForm, rules } from '@vitral/forms';
createForm({
rules: {
name: [rules.required(), rules.maxLength(80)],
age: [rules.min(18, 'You have to be 18.')],
website: [rules.url()],
confirm: [rules.equalsField('password', 'The passwords differ.')],
// Anything else: return a message, or nothing when it is fine.
handle: [rules.custom(async (value) => ((await taken(value)) ? 'That handle is taken.' : undefined))]
},
// Cross-field checks, beside the rules and the resolver.
validate: (values) => (values.start > values.end ? { end: 'The end comes before the start.' } : null)
});Schema validators
A resolver validates the whole form in one call, which is what a schema library is for. Each adapter is written against the smallest shape its library exposes — safeParse, validate, ~standard — so @vitral/forms depends on none of them, and none of them is bundled unless you import it. Rules, validate and a resolver can be used at once: their errors are merged by path.
import { z } from 'zod';
import { zodResolver } from '@vitral/forms';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
handle: z.string().trim().toLowerCase()
});
const resolver = zodResolver(schema);
// Zod 3 and 4 both fit. Async refinements are awaited unless you pass
// `{ async: false }`, and what the schema transforms is what is submitted.import * as yup from 'yup';
import { yupResolver } from '@vitral/forms';
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8)
});
// Every error, not just the first: the adapter passes `abortEarly: false`.
const resolver = yupResolver(schema);import * as v from 'valibot';
import { valibotResolver, standardSchemaResolver } from '@vitral/forms';
const schema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8))
});
// Valibot 1 implements Standard Schema, so either of these works:
const resolver = valibotResolver(schema);
const same = standardSchemaResolver(schema);
// An older schema, or an async one, takes Valibot's own parser:
const asynchronous = valibotResolver(schema, { safeParse: v.safeParseAsync });import { object, string, size, pattern } from 'superstruct';
import { superstructResolver } from '@vitral/forms';
const struct = object({
email: pattern(string(), /^[^@\s]+@[^@\s]+$/),
password: size(string(), 8, 72)
});
// `coerce` applies the struct's coercions to what is submitted.
const resolver = superstructResolver(struct, { coerce: true });import { type } from 'arktype';
import { standardSchemaResolver } from '@vitral/forms';
const schema = type({ email: 'string.email', password: 'string >= 8' });
// ArkType, Valibot 1, Zod 3.24 and later, and anything else carrying
// `~standard`: one adapter covers the lot.
const resolver = standardSchemaResolver(schema);And when the check is a few lines rather than a schema:
import { functionResolver } from '@vitral/forms';
// Errors by path, or nested the way the values are. Anything falsy is "fine".
const resolver = functionResolver(async (values, { signal }) => ({
email: !values.email.includes('@') ? 'That is not an address.' : undefined,
confirm: values.password !== values.confirm ? 'The passwords differ.' : undefined,
address: { postcode: (await lookup(values.address.postcode, { signal })) ? undefined : 'No such postcode.' }
}));What each adapter needs
| Adapter | Library | What it calls | Transforms reach the submitted values |
|---|---|---|---|
zodResolver | Zod 3 and 4 | safeParse, and safeParseAsync when the schema has async refinements | Yes |
yupResolver | Yup | validate, called with abortEarly: false | Yes, the cast value |
valibotResolver | Valibot | ~standard, or the safeParse you hand it | Yes |
superstructResolver | Superstruct | validate, which returns [error, value] | With coerce |
standardSchemaResolver | ArkType, Effect Schema, anything with ~standard | ~standard.validate | Yes |
functionResolver | your own function | nothing | No |
Every adapter is asynchronous as far as the form is concerned, and a validation that a newer one replaces is aborted through the signal it is given. An issue's path is read the way each library reports it — a dotted string, an array of keys, a list of segments — and flattened to address.city, which is the path a field is registered under.
Errors from a server
A server's answer is validation too. normalizeErrors takes it in whatever shape it arrives and flattens it to paths; setErrors puts it on the form, and those paths count as validated, so the messages show at once.
import { normalizeErrors, mergeErrors } from '@vitral/forms';
// 422 from the server, in whatever shape it uses:
const fromServer = normalizeErrors({ address: { city: 'Unknown city.' }, email: ['Already registered.'] });
// → { 'address.city': ['Unknown city.'], email: ['Already registered.'] }
form.setErrors(fromServer);
const everything = mergeErrors(form.getState().errors, fromServer);In a Vue form
Under Vue the same resolver goes on Form.Root, and the fields bind themselves: a control inside a Form.Field takes its value, its name, its invalid state and the relations between its label, hint and error message.
<script setup lang="ts">
import { Form, InputText } from '@vitral/vue';
import { zodResolver } from '@vitral/forms';
import { z } from 'zod';
const schema = z.object({ email: z.string().email(), password: z.string().min(8) });
const values = ref({ email: '', password: '' });
</script>
<template>
<Form.Root v-model="values" :resolver="zodResolver(schema)" @submit="save">
<Form.Field name="email" label="Email">
<InputText type="email" />
</Form.Field>
<Form.Field name="password" label="Password">
<InputText type="password" />
</Form.Field>
<Form.Summary />
<Button type="submit" label="Create account" />
</Form.Root>
</template>The Form component page shows it running, with field arrays, async checks and a summary that takes focus after a failed submit.