DEV Community

Cover image for Zod 4 vs Valibot vs ArkType: A Type-System Teardown
Gabriel Anhaia
Gabriel Anhaia

Posted on

Zod 4 vs Valibot vs ArkType: A Type-System Teardown


You picked Zod three years ago and never looked back. The schemas validate, the types infer, your team knows it. Then someone links an ArkType benchmark page showing it parses several times faster than Zod on a nested object. A frontend teammate replies that Valibot trims their auth bundle from 17.7KB to 1.37KB on the same login form. You wonder if you've been wrong this whole time.

Defaulting to Zod was a fine call. It is also worth a second look now that v4 is out and the alternatives have grown up. The three libraries solve the same surface problem: runtime validation that infers static types. They get there with three completely different type-system mechanics. Zod 4 builds a class hierarchy with chainable methods. Valibot exports plain functions you compose like Lego. ArkType ships a parser that turns string literals into types. The output looks similar. The internals are not even in the same family.

This post is the teardown: same login form in all three, the type-system tricks each one uses, benchmark numbers with sources, and a 3-question checklist to pick one.

The same schema in three dialects

Login form. Email, password (min 8, must contain a digit), and an optional rememberMe boolean. That is the shape we will validate three times. Each snippet below assumes the same input value:

const input = {
  email: "user@example.com",
  password: "hunter22",
  rememberMe: true,
};
Enter fullscreen mode Exit fullscreen mode

Zod 4

import { z } from "zod";

const LoginSchema = z.object({
  email: z.email(),
  password: z
    .string()
    .min(8, "At least 8 characters")
    .regex(/\d/, "Must contain a digit"),
  rememberMe: z.boolean().optional(),
});

type Login = z.infer<typeof LoginSchema>;

const result = LoginSchema.safeParse(input);

if (!result.success) {
  console.error(result.error.issues);
} else {
  const data: Login = result.data;
  console.log(data.email);
}
Enter fullscreen mode Exit fullscreen mode

z.string() returns a ZodString instance. .min(8) returns a new instance with an extra check appended. The chain is method-on-class, and z.infer walks the class instance to compute the static type.

Valibot

import * as v from "valibot";

const LoginSchema = v.object({
  email: v.pipe(v.string(), v.email()),
  password: v.pipe(
    v.string(),
    v.minLength(8, "At least 8 characters"),
    v.regex(/\d/, "Must contain a digit"),
  ),
  rememberMe: v.optional(v.boolean()),
});

type Login = v.InferOutput<typeof LoginSchema>;

const result = v.safeParse(LoginSchema, input);
if (result.success) {
  const data: Login = result.output;
}
Enter fullscreen mode Exit fullscreen mode

No classes. v.string() returns a plain object describing a string schema. v.pipe composes a base with validation actions. v.safeParse is a free function. Everything is a separate import, which is the entire point.

ArkType

import { type } from "arktype";

const Login = type({
  email: "string.email",
  password: "string >= 8",
  "rememberMe?": "boolean",
});

type LoginT = typeof Login.infer;

const out = Login(input);
if (out instanceof type.errors) {
  console.error(out.summary);
} else {
  const data: LoginT = out;
}
Enter fullscreen mode Exit fullscreen mode

That string "string >= 8" is not a comment. It is a type. ArkType parses the string at the type level and produces a TypeScript type that exactly matches the runtime check. The ? suffix marks rememberMe optional. The library doubles as a parser for an embedded DSL, and the parsing happens in the type system as much as at runtime.

For the digit-must-be-present rule, ArkType uses a narrowing predicate:

const Login = type({
  email: "string.email",
  password: type("string >= 8").narrow(
    (s, ctx) => /\d/.test(s) || ctx.mustBe("contain a digit"),
  ),
  "rememberMe?": "boolean",
});
Enter fullscreen mode Exit fullscreen mode

Three libraries, one schema, three different mental models.

Type-system mechanics: where each one earns its rent

Zod: classes plus chain inference

Zod's primitives are classes — ZodString, ZodObject, ZodArray, ZodOptional. Methods like .min() and .optional() return new instances of the same or related class. The static type comes from a phantom generic parameter on each class:

// Approximation — real Zod uses a richer phantom-types layout
class ZodString<T extends string = string> {
  readonly _output!: T;
  min(len: number): ZodString<T> { /* ... */ }
}

class ZodOptional<T extends ZodTypeAny> {
  readonly _output!: T["_output"] | undefined;
}

type infer<T> = T extends { _output: infer O } ? O : never;
Enter fullscreen mode Exit fullscreen mode

z.infer<typeof LoginSchema> walks the object, pulls _output from each child, and assembles the result. It works. It is also why Zod's type-checking time grows with schema depth: each chain link is another generic instantiation for the compiler to resolve.

Valibot: tree-shakeable functional API

Valibot is the polar opposite. Every primitive is a free function returning a plain object. No classes, no this. The output type lives on a phantom property and InferOutput reads it.

// Approximation — real Valibot uses richer schema metadata
function string(): { kind: "string"; _output: string } { /* ... */ }
function minLength<N extends number>(n: N) { /* ... */ }
function pipe<A, B>(a: A, b: B): /* combined */ { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The functional shape unlocks tree-shaking. Your bundler sees that you imported v.string, v.email, and v.object — and only those end up in the build. PkgPulse's 2026 guide reports the same login form at ~1.37KB on Valibot 1.x versus 17.7KB on Zod 4 standard. The Builder.io blog post on Valibot explains why the modular API gets there: your bundler can drop everything you did not import.

The cost: composition is verbose. v.pipe(v.string(), v.email()) is louder than z.string().email(). You pay in characters per schema for the bundle savings.

ArkType: parser-as-types DSL

ArkType is the wild one. The argument to type() is a string (or an object whose values are strings or nested types). That string is syntax for a TypeScript subset — "string", "number > 0", "string | number", "(string | number)[]". ArkType parses it twice: once at runtime to build a validator, and once in the type system using template literal types and recursive conditional types.

A toy version of the type-level parser:

type ParseScalar<S extends string> =
  S extends "string" ? string :
  S extends "number" ? number : never;

type ParseUnion<S extends string> =
  S extends `${infer L} | ${infer R}`
    ? ParseScalar<L> | ParseUnion<R>
    : ParseScalar<S>;

type T = ParseUnion<"string | number">;
// T = string | number
Enter fullscreen mode Exit fullscreen mode

Real ArkType handles constraints (> 0), optionality (name?), arrays, tuples, intersections, branded types, and more. The full grammar lives on arktype.io. The mechanic is the same: read the string at the type level, return a type.

The payoff: definitions are compact and editor errors fire inline against the schema string. The cost: the type-checker is doing real work. For very large schemas, ArkType compile times can become the bottleneck. The maintainers discuss this publicly and ship a .scope() API to amortize the work.

Three opened books on a table, each labeled with a different type-system mechanic

Benchmarks: parse speed and bundle size

Quoting numbers from public benchmarks here, with sources. Run them on your own schema before betting an SLA on them.

Parse speed

The Pockit comparison from 2026 reports operations-per-second on three workloads, tested against Zod 3.24, Valibot 1.0, and ArkType 2.1:

Workload ArkType Valibot Zod 3.24
Simple object 4.52M ops/sec 3.89M ops/sec 1.25M ops/sec
Nested object (3 levels) 1.82M ops/sec 1.46M ops/sec 0.41M ops/sec
Array of 100 objects 41.2K ops/sec 35.8K ops/sec 11.4K ops/sec

The PkgPulse 2026 guide reports total time across 1M iterations on a complex 8-field nested object:

Library Time for 1M iterations
ArkType 820ms
Valibot 1,140ms
Zod v4 1,380ms
Zod v3 4,200ms

Read together: ArkType benchmarks the fastest of the three, but the magnitude depends on the workload and the Zod version. Against Zod 3, ArkType lands roughly 3-4× faster on nested workloads (Pockit's ops/sec table). Against Zod 4, the gap narrows to about 1.7× on the PkgPulse 1M-iteration test (820ms vs 1,380ms). Valibot sits between the two, closer to ArkType than to Zod 3. The InfoQ writeup of the v4 release calls out the parsing work explicitly — that is the whole reason the Zod-3-vs-Zod-4 gap exists.

ArkType's homepage micro-benchmark cites 14ns vs Zod's 281ns for one object validation, roughly 20×. That is one specific workload on Node v23.6.1, and the homepage chart is log-scaled (arktype.io citing the moltar/typescript-runtime-type-benchmarks repo). Treat it as a ceiling, not a typical result.

Sources: Pockit, PkgPulse, ArkType, InfoQ on Zod v4.

Bundle size

Bundle is where Valibot stops being a peer and becomes the only choice — if bundle is what you optimize.

Library (login form) Bundle
Valibot 1.x ~1.37 KB
Zod Mini (v4) ~6.88 KB
Zod v4 standard ~17.7 KB
ArkType 2.x* ~40 KB

* PkgPulse's login-form table does not report a figure for ArkType. The ~40 KB row is Pockit's full-bundle / tree-shaken measurement (42.1KB / 39.8KB), included here for rough comparison — it is not a login-form-specific number.

The login-form numbers for Valibot and Zod come from PkgPulse 2026. Pockit's full-bundle measurements line up directionally: Valibot 1.0 at 8.7KB full / 1.4KB tree-shaken, Zod 3.24 at 14.2KB / 12.1KB, ArkType 2.1 at 42.1KB / 39.8KB. ArkType ships its parser, narrowing engine, and JIT compiler regardless. That is fixed overhead.

If you are a frontend fighting for every kilobyte, Valibot wins by a margin nothing else can close. If you are a backend loading validators once at process start, the bundle column is irrelevant.

Type-check time (qualitative)

Hard to benchmark publicly — it depends on your schema, tsconfig, editor, and machine. The pattern from public discussions and real schemas:

  • Zod: scales linearly with chain depth. Big union schemas can slow tsc noticeably but rarely catastrophically.
  • Valibot: lightest type-check load of the three. The functional API produces shallow types the compiler resolves quickly.
  • ArkType: fast for small-to-medium schemas, can spike on very large ones. The team ships .scope() and other primitives specifically to keep it manageable.

If your editor pauses on schema files for more than a second, the validator is a suspect.

Where each one shines and where each one breaks down

Zod

Shines when: your team already knows it, your stack is "validate at the API edge and forget about it," and you want the deepest ecosystem (tRPC, React Hook Form, OpenAPI generators, every form library on npm). Zod 4 with Mini is competitive on bundle where you can use it.

Breaks down when: a frontend fights for every KB and the tree-shaker can't help because Zod is class-based. The chain API also makes deep custom error formatting awkward — many teams end up with a wrapper that flattens result.error.issues.

Valibot

Shines when: you live and die by bundle size. Edge functions, embedded widgets, marketing sites where the validator competes with the rest of the JS budget. The functional API also reads cleanly once you accept the verbosity.

Breaks down when: you have a sprawling schema graph with many refinements and transforms — the pipe-everything style starts to read like Lisp without parens. Fewer third-party integrations than Zod, too; expect to be the first person on your team writing the adapter.

ArkType

Shines when: schemas are the center of your domain and you want them to read like TypeScript. Validation-heavy backends, parsers, internal DSLs, anywhere an extra zero in ops/sec matters because you validate hot in a loop. The string DSL is dense in a good way once you trust it.

Breaks down when: bundle is the constraint, or your team has not seen the DSL before. The string syntax has a learning curve and the type errors, while better in 2.0, can still bewilder people. Expect a week of "what does this string mean" PR comments after introducing it.

A magnifier hovering over three stacked validators with chain, tree, and parser icons

A 3-question checklist for picking one

Most teams pick wrong because they optimize for the wrong axis. Ask these three in order.

1. Does your bundle budget care about 15KB?

If yes (frontend team, edge-runtime shop, widget vendor), go straight to Valibot. The gap is not closeable by Zod or ArkType without rewriting them. Stop reading.

If no, continue.

2. Is validation in your hot path?

If you validate millions of payloads per minute and the validator shows up in flame graphs, ArkType's parse advantage matters — depending on the Zod version you are migrating from, that is roughly 1.7× over Zod 4 and 3-4× over Zod 3. If you validate at API boundaries at human request rate, all three are fast enough and the speed argument is theater. 240ms across a million validations only matters if you are running a million validations.

If hot-path matters, ArkType — and the win is largest if you would otherwise still be on Zod 3.

3. Does your team already use Zod, in a non-trivial codebase?

If yes, stay on Zod 4. Migration across schemas, error handlers, integrations, and generated docs is days to weeks of work — and questions 1 and 2 already gave you the answer. Zod 4 has closed enough of the speed and bundle gap that staying is cheap and switching is expensive.

Starting fresh? Questions 1 and 2 pick for you.

This is not the full decision matrix. Type-system features, integration ecosystem, and team familiarity all matter. But these three questions cover most real picks.

Closing

The interesting story is not which one wins. It is that three libraries with the same surface goal landed on three completely different type-system techniques, each with a real cost-benefit profile mapped to a different team's needs. Zod's class-based chain is the easy on-ramp; the other two each carve off a constraint Zod cannot answer for cheaply.

If you are picking today, run your real schema through all three on your hardware. Pick the one your team will still be happy with in a year. If you are already on Zod and not unhappy, you are fine.

The deeper takeaway: TypeScript's type system is rich enough to host wildly different DSLs on top of it. The next time someone benchmarks these on a flat object, ask them to redo it on the schema graph that actually scares your CI.


If this was useful

The deeper material on how parser-as-types works, how generic chains scale, and how to read library type definitions at this level is in The TypeScript Type System. Pick the entry point that matches where you are.

The TypeScript Library — 5-book collection:

  1. TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — types, narrowing, modules, async, daily-driver tooling
  2. The TypeScript Type System — From Generics to DSL-Level Types — generics, mapped/conditional types, infer, template literals, branded types
  3. Kotlin and Java to TypeScript — A Bridge for JVM Developers — variance, null safety, sealed→unions, coroutines→async/await
  4. PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — sync→async paradigm, generics, discriminated unions
  5. TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR

Books 1 and 2 are the core path. If you are already a JVM or PHP dev, books 3 or 4 substitute for book 1. Book 5 is for anyone shipping TypeScript at work.

The TypeScript Library — the 5-book collection

Top comments (0)