Vitral 0.2
Reference

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.

Example
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.

Example
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.

Example
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.
Example
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);
Example
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 });
Example
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 });
Example
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:

Example
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

AdapterLibraryWhat it callsTransforms reach the submitted values
zodResolverZod 3 and 4safeParse, and safeParseAsync when the schema has async refinementsYes
yupResolverYupvalidate, called with abortEarly: falseYes, the cast value
valibotResolverValibot~standard, or the safeParse you hand itYes
superstructResolverSuperstructvalidate, which returns [error, value]With coerce
standardSchemaResolverArkType, Effect Schema, anything with ~standard~standard.validateYes
functionResolveryour own functionnothingNo

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.

Example
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.

Example
<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.