Alternative Schema Libraries
Zod is the schema library this site uses most, and for good reason: its TypeScript inference, composable refinements and clear error objects make it an excellent default for form validation. But it is not the only choice, and for some projects it is not the best one. A marketing landing page that ships 15 kB of schema library to validate three fields has a bundle-size problem; a long-lived React codebase built around Formik has thousands of lines of Yup; a team that wants the fastest possible runtime validation for large payloads may reach for ArkType. Each library models the same idea — a declarative description of valid data that produces typed output and structured errors — with different trade-offs in size, speed, API style and ecosystem support. This topic compares them for the job this site cares about: validating forms in the browser and on the server, and delivering errors through setCustomValidity() and reportValidity() from the Constraint Validation API.
The practical problem it addresses is lock-in. Form code that calls a library’s API directly in every component is expensive to migrate; form code written against a small adapter — or the Standard Schema interface these libraries now share — can switch libraries in an afternoon.
Prerequisites for Evaluating Schema Libraries
| Requirement | Minimum version | Why it is needed |
|---|---|---|
| TypeScript | 5.0+ | Inference quality differs sharply between libraries |
| A bundler with tree-shaking | Vite, esbuild, Rollup, webpack 5 | Functional libraries only stay small when unused code is dropped |
| Bundle analyser | rollup-plugin-visualizer, source-map-explorer |
Measuring the real cost in your build |
| A representative schema | — | Benchmarks on toy schemas mislead |
| Form library compatibility | React Hook Form resolvers, VeeValidate, TanStack Form | Integration effort differs per library |
| Server runtime | Node 18+, edge | All four run on the server; check edge bundle limits |
Schema Library API Reference
The same sign-up rule in four libraries shows how much, and how little, the APIs differ.
| Library | Email field | Parse without throwing | Error list |
|---|---|---|---|
| Zod 3 | z.string().email("…") |
schema.safeParse(v) |
result.error.issues |
| Valibot 1 | v.pipe(v.string(), v.email("…")) |
v.safeParse(schema, v) |
result.issues |
| Yup 1 | yup.string().email("…").required() |
schema.validate(v, { abortEarly: false }) (throws) |
err.inner |
| ArkType 2 | type({ email: "string.email" }) |
schema(v) returns data or ArkErrors |
out instanceof type.errors |
| Standard Schema | — | schema["~standard"].validate(v) |
result.issues (path + message) |
The last row matters most. Zod, Valibot and ArkType (among others) implement the Standard Schema interface, a tiny shared contract that lets form libraries and your own code accept any compliant schema. Yup does not implement it natively at the time of writing, but a ten-line adapter makes it compatible.
Step-by-Step Implementation
1. Write the same schema in the candidate libraries
// Zod 3
import { z } from "zod";
export const zodSignup = z.object({
email: z.string().trim().email("Enter an email address like name@example.com."),
password: z.string().min(12, "Password must be at least 12 characters."),
age: z.coerce.number().int().min(16, "You must be 16 or older."),
});
// Valibot 1
import * as v from "valibot";
export const valibotSignup = v.object({
email: v.pipe(v.string(), v.trim(), v.email("Enter an email address like name@example.com.")),
password: v.pipe(v.string(), v.minLength(12, "Password must be at least 12 characters.")),
age: v.pipe(v.unknown(), v.transform(Number), v.integer(), v.minValue(16, "You must be 16 or older.")),
});
// Yup 1
import * as yup from "yup";
export const yupSignup = yup.object({
email: yup.string().trim().email("Enter an email address like name@example.com.").required("Enter your email address."),
password: yup.string().min(12, "Password must be at least 12 characters.").required("Enter a password."),
age: yup.number().typeError("Age must be a number.").integer().min(16, "You must be 16 or older.").required(),
});
Writing a real schema in each candidate exposes the differences that matter for your forms — how coercion from strings is expressed, how required-ness works (Yup fields are optional unless .required(); Zod and Valibot fields are required unless optional), and how messages are attached.
2. Put every library behind the same small interface
// validation/adapter.ts — the only file that knows which library you use
export type FieldErrors = Record<string, string>;
export type Result<T> = { ok: true; data: T } | { ok: false; errors: FieldErrors };
import type { StandardSchemaV1 } from "@standard-schema/spec";
export async function validate<T>(schema: StandardSchemaV1<unknown, T>, input: unknown): Promise<Result<T>> {
const result = await schema["~standard"].validate(input);
if (!result.issues) return { ok: true, data: result.value };
const errors: FieldErrors = {};
for (const issue of result.issues) {
const key = (issue.path ?? []).map((p) => (typeof p === "object" ? p.key : p)).join(".");
errors[key] ??= issue.message;
}
return { ok: false, errors };
}
With this adapter, form code never imports Zod or Valibot directly; it calls validate(schema, data) and gets field-keyed messages. The details of the interface are in Standard Schema: library-agnostic validation.
3. Deliver errors through the native API, whatever the library
import { validate } from "./adapter";
import { valibotSignup } from "./schemas";
const form = document.querySelector<HTMLFormElement>("#signup")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const result = await validate(valibotSignup, Object.fromEntries(new FormData(form)));
for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) {
el.setCustomValidity(result.ok ? "" : result.errors[el.name] ?? "");
}
if (form.reportValidity()) form.submit();
});
Swapping valibotSignup for zodSignup changes nothing else in this file. That is the whole point of the adapter.
4. Measure the real cost in your build
# Build the form's entry once per library and compare the gzipped size of the chunk
npx vite build --mode analyze
npx source-map-explorer dist/assets/signup-*.js --gzip
Measure with your actual schema, not a published benchmark. Valibot’s advantage is largest for small forms, because its functional design lets bundlers drop every validator you do not import; for very large schemas with many validators the gap narrows. The Valibot-specific recipe is Valibot form validation.
State Management and Edge Cases
Schema libraries differ in behaviours that surface as form bugs rather than type errors.
- Required by default or optional by default. Yup treats fields as optional unless
.required(); forgetting it lets empty strings through for fields that must be filled. Zod and Valibot require by default. Migrating between them silently changes which fields are required unless you audit every field. - Empty strings versus undefined. Form inputs send
""for empty text fields. A schema that treats""as “present” accepts an empty required field. Normalise""toundefinedfor optional fields, or requiremin(1)for required ones. - Abort early. Yup’s
validatestops at the first error unless{ abortEarly: false }; forms nearly always want all errors at once. - Sync versus async. Zod and Valibot have separate async parse functions (
safeParseAsync,v.safeParseAsync) that are required as soon as any refinement is async; calling the sync version throws. Standard Schema’svalidatemay return a promise, so alwaysawaitit. - Transform ordering. Trimming before or after the email check changes results. Make normalisation the first step in each field’s pipe.
- Unknown keys. Zod and Valibot strip unknown object keys by default; Yup keeps them unless
.noUnknown()is set. On the server that difference decides whether a crafted extra field reaches your database.
Accessibility Compliance Is Library-Independent
No schema library makes a form accessible or inaccessible on its own; what matters is how their messages reach the page. WCAG 3.3.1 and 3.3.3 require that errors be identified in text and suggest fixes, which is a property of the message strings you attach to each rule, not of the library. Write messages as full sentences that say what to do — “Enter an email address like name@example.com” — rather than relying on library defaults such as “Invalid email” or “String must contain at least 12 character(s)”, which are terse, untranslated and occasionally leak implementation wording. Then deliver them through setCustomValidity() and reportValidity(), or through your form library’s error rendering with aria-describedby, exactly as elsewhere on this site. A useful habit when adopting any library is a test that fails if any schema produces a message not found in your message catalogue, as described in keeping client and server error messages in sync.
Common Gotchas and Debugging
Library default messages in production. Every library has English defaults. Set a custom message on every rule, or configure a global error map/messages function once.
// Before: default message leaks through
z.string().email(); // "Invalid email"
// After: a message that tells the user what to do
z.string().email("Enter an email address like name@example.com.");
Yup’s typeError. yup.number() on a form string "abc" fails with a type error whose default text mentions NaN. Always add .typeError("Age must be a number.").
Path formats differ. Zod and Valibot report paths as arrays of keys; Yup reports a dotted string such as items[0].qty. Normalise paths in your adapter so every library produces items.0.qty, matching input names.
Mixing libraries in one schema. Composing a Zod object with a Valibot field does not work. Pick one library per schema tree; use Standard Schema only at the boundary where schemas are consumed.
Migrating Between Schema Libraries
Migration is safest when done schema by schema behind the adapter. Write the new library’s schema next to the old one, run both against the same fixture set, and diff their outputs: data, error paths and messages. Differences are either bugs you are fixing (document them) or behaviour you are changing (decide deliberately). Once a schema’s outputs match, switch the form to it and delete the old one. The usual surprises are required-by-default semantics, empty-string handling and coercion; the Yup-to-Zod specifics are covered in Yup vs Zod for form validation.
import { describe, it, expect } from "vitest";
import { validate } from "./adapter";
import { zodSignup, valibotSignup } from "./schemas";
const FIXTURES = [
{ email: "ada@example.com", password: "correct horse battery", age: "36" },
{ email: "not-an-email", password: "short", age: "12" },
{ email: "", password: "", age: "" },
];
describe("migration parity", () => {
it.each(FIXTURES)("Zod and Valibot agree on %o", async (input) => {
expect(await validate(valibotSignup, input)).toEqual(await validate(zodSignup, input));
});
});
Async Rules Across Libraries
Every library supports asynchronous rules, and every one makes them contagious: once a single field has an async refinement, the whole schema must be parsed asynchronously. That has consequences for forms. Parsing on every keystroke with an async schema fires the async rule on every keystroke too, including network lookups. The cleaner pattern, regardless of library, is to keep the schema synchronous and run async checks separately with their own debounce and cancellation, merging their verdicts into the field errors — the approach in asynchronous server checks.
// Zod: async refinement forces safeParseAsync for the whole object
z.object({ username: z.string().refine(async (u) => await isFree(u), "That username is taken.") });
// Valibot: checkAsync inside pipeAsync, and objectAsync at the top level
v.objectAsync({ username: v.pipeAsync(v.string(), v.checkAsync(isFree, "That username is taken.")) });
// Yup: test() may return a promise; validate() is always async
yup.object({ username: yup.string().test("free", "That username is taken.", (u) => isFree(u ?? "")) });
If you do need an async rule inside a schema — typically on the server, where the check runs once per submission — keep it at the edge of the tree and make sure everything that parses the schema awaits the result. The Standard Schema adapter above already awaits, so it handles both kinds transparently.
Using Alternative Schemas With Form Libraries
Form libraries increasingly accept any Standard Schema directly, which removes most of the integration cost of choosing a non-Zod library. TanStack Form accepts Standard Schema validators natively; React Hook Form’s resolver package ships resolvers for Zod, Valibot, Yup, ArkType and a generic Standard Schema resolver; VeeValidate offers toTypedSchema adapters for Zod, Valibot and Yup. Before committing to a library, check that your form library’s integration supports the features you use — async validation, per-field validation on blur, and nested paths — because support varies by adapter version. The library-specific wiring is covered in integrating Zod resolver with React Hook Form and TanStack Form validation.
Error Shapes and Path Normalisation
The part of each library that form code touches most is its error object, and the shapes differ more than the schema APIs do. Zod reports an array of issues, each with a path array of keys and indexes, a code and a message. Valibot reports an array of issues with a path array of objects carrying key and value. Yup throws a ValidationError whose inner array holds one error per failure, each with a dotted-and-bracketed path string. ArkType returns an ArkErrors collection with paths and messages. Forms need one shape: a map from input name to message. Normalise in exactly one place — the adapter — so that items.0.qty comes out the same whichever library produced it, and so the error mapping code described in mapping server field errors to form inputs works unchanged for client-side results too.
Server-Side Considerations
On the server, bundle size stops mattering and throughput and cold-start time start to. For typical form submissions — a few dozen fields, one parse per request — every library here validates in well under a millisecond, and the choice should follow the client. Throughput only becomes a deciding factor when validating large payloads or high request volumes, such as bulk imports or event ingestion, where ArkType’s compiled validators and Valibot’s lean runtime can outperform Zod 3 noticeably. Cold starts on serverless and edge platforms are affected by module size and initialisation work, which favours the smaller libraries; measure with your deployment target rather than a laptop benchmark. And whatever runs on the server should be the same schema the client uses, so the choice of library is ultimately a choice for both sides at once, as described in shared client–server schemas.
Choosing a Library for Your Forms
A short decision guide covers most situations. If you already use Zod across client and server, keep it — the ecosystem support (resolvers, tRPC, form libraries) is broad, and Zod 4’s Mini variant addresses bundle size. If bundle size is the binding constraint, especially for small, public, performance-sensitive forms, choose Valibot. If you maintain a Formik codebase, Yup is already there; migrate only when you have another reason to touch the forms. If you validate large volumes of data on the server and want maximum throughput, evaluate ArkType. And whichever you choose, keep form code behind a Standard Schema adapter so the choice stays reversible.
Browser Compatibility Matrix
| Library | Chromium | Firefox | Safari | Notes |
|---|---|---|---|---|
| Zod 3 / 4 | Yes | Yes | Yes | ES2018+ output |
| Valibot 1 | Yes | Yes | Yes | ES2020+ output; tree-shakes fully |
| Yup 1 | Yes | Yes | Yes | Uses property-expr for paths |
| ArkType 2 | Yes | Yes | Yes | Larger runtime; fast validation |
| Standard Schema types | n/a | n/a | n/a | Types only; zero runtime cost |
Frequently Asked Questions
What are the main alternatives to Zod for form validation?
Valibot, which is much smaller for typical forms thanks to its functional, tree-shakeable design; Yup, the long-standing choice in Formik codebases; and ArkType, which uses a type-syntax API and is very fast at runtime. Zod 4 also offers a smaller Mini variant.
Which schema library has the smallest bundle size?
For small forms, Valibot typically adds one to two kilobytes gzipped after tree-shaking, and Zod 4 Mini a few kilobytes. Measure with your own schema, because the gap narrows as schemas use more validators.
How can I avoid being locked into one schema library?
Consume schemas through the Standard Schema interface, which Zod, Valibot and ArkType implement, behind a small adapter that returns field-keyed errors. Form code then does not import the library directly.
Do schema libraries affect accessibility?
Not directly. What matters is the messages you attach to each rule and how you deliver them — through setCustomValidity and reportValidity, or aria-describedby in a form library. Replace terse library defaults with messages that say how to fix the problem.
Related Guides
- Valibot Form Validation — the small-bundle option in practice.
- Yup vs Zod for Form Validation — differences and migration.
- Standard Schema: Library-Agnostic Validation — the shared interface.
- Schema-Based Validation with Zod — the site’s default library in depth.
← Back to Advanced JavaScript Validation Logic & Patterns