Schema validation alternatives
Valibot, ArkType, and the trade between bundle size, ergonomics, and what you already know.
Zod is the popular default and a fine one. It is also not the only library doing this job, and a few of the alternatives are worth knowing about — partly because they make different tradeoffs, partly because the choice affects bundle size in ways you'll notice on a marketing page that ships 40 KB of validators it barely uses. This lesson is a quick tour of the two most common alternatives and a frame for choosing.
Valibot
Valibot is the obvious answer to "I like Zod's idea but want a smaller bundle." It does the same job — runtime validation with TypeScript inference — and the API is recognizable.
import * as v from "valibot";
const User = v.object({
id: v.string(),
name: v.string(),
age: v.number(),
});
const parsed = v.parse(User, raw);
type User = v.InferOutput<typeof User>;The shape will feel familiar after Zod. The differences are mostly stylistic.
v.parse(schema, value) instead of schema.parse(value) — Valibot's API is functional, not method-chained. Schemas are plain values; the operations on them are top-level functions. This is what lets bundlers tree-shake the parts you don't use.
The inference type is v.InferOutput<typeof User> instead of z.infer. The name is more explicit — InferOutput because Valibot also has InferInput for cases where parsing transforms the value (think: a string is parsed into a Date). For schemas without transforms, the two are the same.
const Email = v.pipe(
v.string(),
v.email("must be a valid email"),
v.maxLength(120),
);Constraints are composed with v.pipe(schema, ...refinements). Where Zod chains methods (z.string().email().max(120)), Valibot lists each step. The pipe form is more verbose but it's also why Valibot tree-shakes: when you don't use email, the validator for it never enters the bundle. Zod's chained methods all live on the same object, so all of them ship whether you call them or not.
If your app uses validators in a few small places — a form here, a fetch wrapper there — Valibot can shave 10–20 KB compared to Zod. If you use validators everywhere, the gap shrinks because Zod's footprint is paid once.
ArkType
ArkType is a different bet. It uses TypeScript-style strings as the schema language.
import { type } from "arktype";
const User = type({
id: "string",
name: "string",
age: "number",
email: "string.email",
status: "'draft' | 'published' | 'archived'",
});
const result = User(raw);
if (result instanceof type.errors) {
console.error(result.summary);
} else {
/* result is the validated value */
}The schema reads almost like a TypeScript type literal — except every value is a string the library parses. "string.email" is ArkType's way of saying "a string constrained to email format." The union syntax with single-quoted literals is the same shape you'd write in a TypeScript type.
The result of calling User(raw) is either the validated value or an instance of type.errors. The instanceof check splits the two paths — TypeScript narrows the variable to the validated shape on the success branch. It's a tagged result like Zod's safeParse, just with a different surface.
The pitch: if you're already fluent in TypeScript types, you can write schemas in syntax you already know. The downside: the schema is a string, which means typos don't get the same kind of editor feedback you'd get from a method call. ArkType's editor plugin and runtime errors do close most of that gap, but it's a different feel.
const Order = type({
id: "string",
items: {
sku: "string",
qty: "number > 0",
price: "number >= 0",
} as const + "[]",
customer: {
name: "string",
email: "string.email | null",
},
});Nesting works by composing object literals and adding [] for arrays. The constraints — "number > 0", "number >= 0" — are part of the string syntax. ArkType also produces faster runtime checks than Zod or Valibot for many shapes because it compiles the schema into a specialized validator function.
"number > 0". What does that mean at runtime?Reading the tradeoffs
Three libraries, three trade-offs. Putting them next to each other:
A library is more than its API. It's the docs you'll search, the GitHub issues you'll read at midnight, the StackOverflow answers, the Stack-shaped knowledge that gets baked into a community. Zod's lead there is real: most tutorials, most validators in the wild, most familiar idioms. The tradeoff: you're paying for that familiarity in bundle size.
A practical heuristic: a small, dynamic-imports-heavy app or a serverless function where cold-start size matters → Valibot is worth a real look. A large product with a Zod-shaped team, lots of existing schemas, and a budget for the extra KB → stick with Zod. A team that loves TypeScript-style strings or wants the fastest runtime for a hot validation path → ArkType.
Don't mix two schema libraries in one app. The bundle cost adds up, and the engineers reading the code have to learn both APIs. Pick one and converge.
How to choose
The honest answer: pick Zod unless you have a reason. The reasons exist and they're real, but most apps don't have them.
Reasons to leave the default:
- Bundle size matters and you're under a budget. Marketing pages, embeddable widgets, mobile-first SPAs that target slow networks. Valibot earns its keep here.
- Validation is on a hot path. Game servers, real-time pipelines, edge functions billed per millisecond. ArkType's compiled validators measure faster than Zod's interpreted ones.
- The team already loves TypeScript syntax for everything. ArkType's strings will feel natural; Zod's chained methods will feel like a translation.
Reasons to stay with Zod:
- Most of what you read about validators on the web assumes Zod. The blog posts, the tutorials, the examples in framework docs. New teammates onboard faster.
- The ecosystem. Zod adapters exist for tRPC, react-hook-form, sanity-zod, drizzle-zod, and a hundred others. Valibot has a growing list; ArkType less so.
- You need consistency across many small validators. Zod's chained API is shorter for trivial schemas, and short matters when you have a hundred of them.
The losing strategy is to pick on vibes and switch a year later. The schemas don't migrate easily; consumers downstream end up rewriting parse calls and import statements across the codebase. Pick once, write the schemas centrally, and live with the call.
"'draft' | 'published' | 'archived'" for a status field. What is the inferred TypeScript type after parsing?