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.

Schema libraries for form validation at a glance A comparison table of Zod, Valibot, Yup and ArkType across API style, approximate minified and gzipped bundle cost for a small form, TypeScript inference, async support and Standard Schema support. API style Small form cost TS inference Standard Schema Zod 3 method chaining 13 kB ✓ Yes ✓ 3.24+ Zod 4 Mini functional 2–4 kB ✓ Yes ✓ Yes Valibot 1 functional pipes 1–2 kB ✓ Yes ✓ Yes Yup 1 method chaining 12 kB partial ✗ No ArkType 2 type-syntax strings 40 kB ✓ Yes ✓ Yes
Sizes are approximate for a small sign-up schema after tree-shaking; they vary by version and by how many validators a form uses.

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.

Library cost for one small sign-up form A bar chart of the approximate gzipped bytes each schema library adds to the bundle for a three-field sign-up schema after tree-shaking. Valibot 1 1.4 kB gzip Zod 4 Mini 3 kB gzip Yup 1 12 kB gzip Zod 3 13 kB gzip ArkType 2 40 kB gzip
Approximate figures from one build; functional libraries shrink most for small forms because unused validators are tree-shaken away.

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 "" to undefined for optional fields, or require min(1) for required ones.
  • Abort early. Yup’s validate stops 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’s validate may return a promise, so always await it.
  • 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.
Chained versus functional schema APIs Two columns comparing method-chaining schema libraries such as Zod 3 and Yup with functional, pipe-based libraries such as Valibot and Zod Mini. Method chaining (Zod 3, Yup) • z.string().trim().email() ✓ fluent, discoverable in the editor ✗ every method is on the object, so all ship • larger baseline bundle Functional pipes (Valibot, Zod Mini) • v.pipe(v.string(), v.trim(), v.email()) ✓ tree-shakes to what you use ✓ tiny bundles for small forms ✗ slightly more verbose
Chaining reads fluently but ships the whole API; functional pipes read slightly more verbosely but only ship what you import.

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.

Choosing a schema library for forms A decision tree that recommends keeping Zod, choosing Valibot for bundle size, keeping Yup for existing Formik code, or evaluating ArkType for server throughput. Already using a library across the stack? yes Keep it; add a Standard Schema adapter no Is client bundle size the main constraint? yes Valibot or Zod Mini no Heavy server-side throughput? yes Evaluate ArkType no Zod
The choice is rarely permanent when forms consume schemas through a Standard Schema adapter.

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.

← Back to Advanced JavaScript Validation Logic & Patterns

Explore This Section