DEV Community

Cover image for I Compared 6 TypeScript Validation Libraries. Here's What Surprised Me 🤔
nyaomaru
nyaomaru

Posted on AI-assisted

I Compared 6 TypeScript Validation Libraries. Here's What Surprised Me 🤔

The hidden danger of mismatched runtime contracts

Hoi hoi! 👋  

I'm nyaomaru, a frontend engineer and OSS developer working mostly with TypeScript.

When we need runtime validation in TypeScript, the conversation often turns into this 👇

"Which library should I use?"

Or maybe, just write a type guard?

I maintain is-kit, so I think about this boundary quite a lot.

And recently I started wondering

Are we comparing libraries that are actually solving the same problem?

For example, if all I want is this

if (isUser(input)) {
  input.name;
}
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

do I really need a schema system?

But if I want:

  • structured validation errors
  • transformations
  • parsing
  • schema metadata
  • framework integrations

then a small boolean guard is probably not enough either.

So instead of asking "Which validator is best?", I tried a different comparison.

I implemented the same User validation with:

Then I compiled, tested them against the same fixtures, measured standalone browser bundle size, and ran a small steady-state runtime benchmark.

The hardest part was not making seven validators compile.

It was making seven validators mean the same thing.

Even “a string with at most 100 characters” can describe different runtime contracts.

So, let's look at what each tool is actually buying us. 😺

TypeScript Validation Battle


🧪 The User we will validate

Here is the shared type.

type User = {
  id: string;
  name: string;
  age: number;
  role: "admin" | "user";
  tags: string[];
  profile: {
    bio: string;
    active: boolean;
  };
};
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

And the runtime rules are:

  • id must be a string
  • name must contain 1–100 UTF-16 code units
  • age must be an integer from 18 to 120
  • role must be exactly "admin" or "user"
  • tags must be an array of strings
  • profile.bio must be a string
  • profile.active must be a boolean
  • extra keys are rejected at the root and inside profile
  • values are not coerced

For this comparison, the main input domain is ordinary JSON-decoded data.

That matters because JavaScript can also give us strange things like inherited properties, sparse arrays, symbols, getters, and non-enumerable keys.

We'll come back to those later.


1. ✍️ Handwritten Guard — Sometimes a Guard Is Enough

Let's start with the boring option, a handwritten JavaScript guard with a TypeScript predicate.

No validation library.

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const hasExactKeys = (
  value: Record<string, unknown>,
  keys: readonly string[],
): boolean =>
  Object.keys(value).length === keys.length &&
  keys.every((key) => Object.hasOwn(value, key));

export const isUser = (value: unknown): value is User => {
  if (
    !isRecord(value) ||
    !hasExactKeys(value, ["id", "name", "age", "role", "tags", "profile"])
  ) {
    return false;
  }

  if (
    !isRecord(value.profile) ||
    !hasExactKeys(value.profile, ["bio", "active"])
  ) {
    return false;
  }

  return (
    typeof value.id === "string" &&
    typeof value.name === "string" &&
    value.name.length >= 1 &&
    value.name.length <= 100 &&
    typeof value.age === "number" &&
    Number.isInteger(value.age) &&
    value.age >= 18 &&
    value.age <= 120 &&
    (value.role === "admin" || value.role === "user") &&
    Array.isArray(value.tags) &&
    value.tags.every((tag) => typeof tag === "string") &&
    typeof value.profile.bio === "string" &&
    typeof value.profile.active === "boolean"
  );
};
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

And then

declare const input: unknown;

if (isUser(input)) {
  input.name.toUpperCase();
}
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

This is still very good.

For one local boundary, a handwritten type guard has a huge advantage

There is no extra model to learn.

It is just JavaScript runtime checks plus a TypeScript predicate.

But the cost starts to appear when your codebase grows this

isUser;
isProfile;
isRole;
isNonEmptyString;
isAdultAge;
isStringArray;
isApiResponse;
isConfig;
isFeatureFlag;
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

Now you are not writing "one guard" anymore.

You are maintaining your own tiny guard library.

And this is where the next option starts to make sense. 👍


2. 🔧 is-kit — When Small Guards Become Reusable Building Blocks

This is the problem I built is-kit around.

I didn't want a schema-first workflow for every narrowing problem.

I wanted small guards that stay small, but compose cleanly.

import {
  and,
  arrayOf,
  isBoolean,
  isNumber,
  isString,
  oneOfValues,
  predicateToRefine,
  safeParse,
  struct,
} from "is-kit";

export const isName = and(
  isString,
  predicateToRefine<string>(
    (value) => value.length >= 1 && value.length <= 100,
  ),
);

export const isAge = and(
  isNumber,
  predicateToRefine<number>(
    (value) => Number.isInteger(value) && value >= 18 && value <= 120,
  ),
);

export const isProfile = struct(
  {
    bio: isString,
    active: isBoolean,
  },
  { exact: true },
);

export const isUser = struct(
  {
    id: isString,
    name: isName,
    age: isAge,
    role: oneOfValues("admin", "user"),
    tags: arrayOf(isString),
    profile: isProfile,
  },
  { exact: true },
);

export const parseUser = (input: unknown) => safeParse(isUser, input);
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

The important part is that the pieces remain normal guards

const names = values.filter(isName);

if (isAge(value)) {
  // value is number
}
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

That is useful when your codebase already thinks in terms of predicates.

And this is also where I would draw the line.

safeParse() gives a small success/failure result around a guard.

It does not turn is-kit into:

  • a field-level issue system
  • a transformation pipeline
  • a schema metadata system
  • an OpenAPI ecosystem
  • a form integration layer

If those are the actual requirements, I would rather use a schema library than try to make a guard toolkit become one.

One type-level detail also showed up in the verification, struct() and arrayOf() infer readonly structures.

So the runtime shape matches our User, but readonly string[] is not assignable to mutable string[].

That difference is real, and the type tests make it explicit instead of hiding it with a cast. 🔧


3. 🏃 Runtypes — A Validator Object, Not Just a Predicate

Runtypes moves one level further.

Instead of just a boolean predicate, you get a runtime validator object with several ways to consume it.

import {
  Array as RtArray,
  Boolean as RtBoolean,
  Literal,
  Number as RtNumber,
  Object as RtObject,
  String as RtString,
  Union,
} from "runtypes";

const Name = RtString.withConstraint(
  (value) =>
    (value.length >= 1 && value.length <= 100) || "name length must be 1..100",
);

const Age = RtNumber.withConstraint(
  (value) =>
    (Number.isInteger(value) && value >= 18 && value <= 120) ||
    "age must be an integer in 18..120",
);

export const UserSchema = RtObject({
  id: RtString,
  name: Name,
  age: Age,
  role: Union(Literal("admin"), Literal("user")),
  tags: RtArray(RtString),
  profile: RtObject({
    bio: RtString,
    active: RtBoolean,
  }).exact(),
}).exact();

export const isUser = (input: unknown) => UserSchema.guard(input);

export const checkUser = (input: unknown) => UserSchema.check(input);

export const parseUser = (input: unknown) => UserSchema.parse(input);

export const inspectUser = (input: unknown) => UserSchema.inspect(input);
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

This feels different from a guard toolkit.

A predicate mainly answers

Is this value a User?

A Runtype can also answer that question, but the same object can:

  • narrow with guard
  • throw with check
  • parse with parse
  • expose structured failure data

Also, check() and parse() should not be treated as synonyms.

Runtypes supports parser behavior, and object handling can differ between validation and parsing contexts.

That is exactly the kind of runtime machinery you do not have in a plain predicate. 🏃


4. 🤖 Valibot — Schema Validation With a Modular API

Valibot is where the comparison starts looking more "schema-like".

import * as v from "valibot";

export const UserSchema = v.strictObject({
  id: v.string(),
  name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
  age: v.pipe(v.number(), v.integer(), v.minValue(18), v.maxValue(120)),
  role: v.picklist(["admin", "user"]),
  tags: v.array(v.string()),
  profile: v.strictObject({
    bio: v.string(),
    active: v.boolean(),
  }),
});

export const isUser = (input: unknown) => v.is(UserSchema, input);

export const parseUser = (input: unknown) => v.safeParse(UserSchema, input);
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

This gives us:

  • schema validation
  • type inference
  • structured issues
  • parsing APIs
  • transformation pipelines

But the API is still function-based and modular.

That is a big part of Valibot's design.

Instead of every capability living as methods on large schema objects, you compose independent functions.

That makes tree shaking a first-class part of the normal API.

And unlike a pure schema-only mental model, Valibot also exposes v.is() as a type guard.

So schema validation and ordinary TypeScript narrowing are not mutually exclusive.

One caveat: v.is() is the guard path. It does not expose validation issues or apply transformations like parsing does. 🤖


5. 💎 Zod — When the Schema Is Part of a Bigger Ecosystem

Now Zod.

import * as z from "zod";

export const UserSchema = z.strictObject({
  id: z.string(),
  name: z
    .string()
    .refine(
      (value) => value.length >= 1 && value.length <= 100,
      "name must contain 1..100 UTF-16 code units",
    ),
  age: z.int().min(18).max(120),
  role: z.enum(["admin", "user"]),
  tags: z.array(z.string()),
  profile: z.strictObject({
    bio: z.string(),
    active: z.boolean(),
  }),
});

export type User = z.output<typeof UserSchema>;

export const parseUser = (input: unknown) => UserSchema.safeParse(input);

// This schema has no transforms, so its output type is safe for the input too.
export const isUser = (input: unknown): input is User => parseUser(input).success;
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

Zod does not expose a built-in is() method, but this adapter can narrow input because this schema has no transforms. With a transforming schema, safeParse().data is the safe typed value: the original input and output types may differ.

Zod is not interesting because it can validate a User.

All of these libraries can do that.

The real value is everything around the schema:

  • form integrations
  • API tooling
  • schema conversion
  • metadata
  • codecs
  • OpenAPI-related tooling
  • framework integrations

If your schema is already becoming part of application infrastructure, that ecosystem matters. 💎

There is one important detail in this sample though.

I did not use this

z.string().min(1).max(100);
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

Why?

Because our contract says

1–100 UTF-16 code units

Zod's built-in string length checks use Unicode code points, so this sample uses .refine() with JavaScript's string.length to preserve the stated UTF-16 contract. The shared emoji fixture demonstrates why below.

What about bundle size?

It is too easy to write

Valibot is small, Zod is large.

That is not a good 2026 comparison anymore.

Zod also has Zod Mini, which uses a more functional, tree-shakable API.

The bundle table later in this article measures regular Zod, not Zod Mini.

So it should not be read as Valibot versus the smallest possible Zod setup.

Zod can also compile optimized validation logic at runtime with z.compile() and new Function.

I did not enable it here.

That makes this distinction more useful than the broad label “generated validation”

runtime schema compilation
  vs
build-time generation from TypeScript types
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

That distinction becomes important when we get to typia.


6. 🚢 ArkType — What If the Runtime Schema Looked Like TypeScript?

ArkType has a very different authoring model.

import { type } from "arktype";

export const UserSchema = type({
  id: "string",
  name: "0 < string <= 100",
  age: "18 <= number.integer <= 120",
  role: "'admin' | 'user'",
  tags: "string[]",
  profile: {
    bio: "string",
    active: "boolean",
  },
}).onDeepUndeclaredKey("reject");

export const isUser = (input: unknown) => UserSchema.allows(input);

export const parseUser = (input: unknown) => UserSchema(input);

export const ArkErrors = type.errors;
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

I like this comparison

z.int().min(18).max(120);
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

vs

"18 <= number.integer <= 120";
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

Neither is "better".

They are different ways to express the runtime contract.

If your team already thinks naturally in TypeScript-like expressions, ArkType is very readable.

allows() also works as a type guard.

And calling the schema directly gives you validated output or an error collection.

Another interesting part: ArkType can precompile optimized traversal logic at runtime when the environment allows new Function.

So ArkType is already doing specialization. 🚢

But it is still not the same architecture as typia.

ArkType starts from a runtime type representation.

typia starts from TypeScript types during the build.


7. 🚀 typia — Generate the Validator During the Build

typia is the outlier in this comparison.

import typia, { type tags } from "typia";

type Utf16Name = tags.TagBase<{
  kind: "utf16Name";
  target: "string";
  value: undefined;
  validate: "$input.length >= 1 && $input.length <= 100";
}>;

export interface User {
  id: string;
  name: string & Utf16Name;
  age: number & tags.Type<"uint32"> & tags.Minimum<18> & tags.Maximum<120>;
  role: "admin" | "user";
  tags: string[];
  profile: {
    bio: string;
    active: boolean;
  };
}

export const isUser = typia.createEquals<User>();

export const validateUser = typia.createValidateEquals<User>();
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

This is not a runtime schema object.

The source of truth is the TypeScript type plus typia's tags.

Then typia's build transform turns

typia.createEquals<User>();
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

into specialized validation code.

That means there is no general runtime schema interpreter walking a User schema object.

But there is a trade-off

You need build integration.

The current official setup uses TypeScript 7 and ttsc / typia's transform integration.

Stock tsc alone does not run the transform.

In my verification workspace, the first typia build also had to compile a Go source plugin and took several minutes.

That setup cost is not part of the runtime benchmark, but it is absolutely part of the adoption decision.

Also, typia's built-in MinLength / MaxLength did not match our UTF-16 contract.

So I used a custom tag for name.

Again, same-looking validation rule, different semantics. 🚀


😹 Wait, Even "String Length" Was Different?

This was the first test that made me stop.

"😀".repeat(51).length;
// 102
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

JavaScript's string.length counts UTF-16 code units.

So with our contract

maximum = 100 UTF-16 code units
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

this should fail.

And it did fail in:

  • handwritten guard
  • is-kit
  • Runtypes
  • Valibot
  • ArkType

But the original Zod and typia samples accepted it.

Why?

Because their built-in length constraints count Unicode code points instead.

For this string

51 emoji
= 51 code points
= 102 UTF-16 code units
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

Neither behavior is wrong.

They are just different contracts.

And neither one necessarily matches "what a human sees" either, because grapheme clusters make Unicode even more fun. 😹

So I changed the Zod sample to .refine() and typia to a custom tag.

That was probably the most useful lesson in the whole comparison 👇

Equivalent-looking schemas are not automatically equivalent runtime contracts.


So What Are We Actually Comparing?

At this point, these no longer look like seven interchangeable libraries.

They are different runtime models.

Approach Source of truth           Natural success API                       Structured failures       Transformation model       Compilation / build requirement                          
Handwritten guard Type + handwritten check   Predicate                                 Manual                     Manual                     None                                                      
is-kit   Composed guards           Predicate / safeParse                   Basic valid/invalid result Guard-oriented             None                                                      
Runtypes Runtype object             guard / check / parse               Yes                         Parsers                   None                                                      
Valibot   Schema functions           is / safeParse                         Yes                         Schema pipelines           None                                                      
Zod       Schema objects             parse / safeParse                     Yes                         Transforms / codecs       No build transform; optional runtime z.compile()        
ArkType   Runtime type expressions   allows / invocation                     Yes                         Morphs                     No build transform; runtime precompilation when available
typia     TypeScript types and tags Generated predicates / validation results With validation APIs       Build-generated functions Build transform required                              

This is why I no longer like thinking about them as a simple upgrade ladder.

A schema library is not "a better type guard".
A generated validator is not "a better schema".

They solve overlapping problems at different layers.


✅ What Actually Passed?

The complete harness, lockfile, fixtures, probes, and generated result files live in typescript-validator-comparison. The article links to that immutable commit, rather than to a moving branch.

To reproduce the recorded snapshot:

git clone https://github.com/nyaomaru/typescript-validator-comparison.git
cd typescript-validator-comparison
git checkout --detach b26350d01fd18cb77d55a95e2f3b112657f0d305
corepack pnpm install --frozen-lockfile
corepack pnpm verify
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

pnpm verify runs lint, strict type checking, fixtures, bundle measurement, benchmarks, JavaScript-only probes, and report generation. The result tables below are that run's saved snapshot, reruns on another machine will naturally produce different timing numbers.

I pinned the versions and ran the same fixture matrix against all seven implementations.

Environment

Date:       2026-10-23
Node:       24.22.1
TypeScript: 7.0.2
pnpm:       11.22.0
ttsc:       0.31.3
tsbuild:    0.28.2
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

Packages

Package   Version
is-kit   1.15.3  
runtypes 7.0.5  
valibot   1.5.0  
zod       4.6.5  
arktype   2.2.8  
typia     15.1.0  

The shared matrix contains 35 JSON fixtures.

That includes:

  • valid users
  • age boundaries
  • name boundaries
  • emoji length cases
  • wrong primitive types
  • missing fields
  • invalid roles
  • invalid array elements
  • extra root keys
  • extra nested keys
  • non-object roots

All seven matched the expected result.

35 fixtures
× 7 validators
= 245 boolean checks
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

All 245 passed.

The harness also checks input mutation, richer APIs, strict TypeScript compilation, lint, builds, narrowing, and bundled output behavior.

So the implementations agree for the JSON-shaped contract in this article—not necessarily for every JavaScript value.


🧩 One More Surprise: "Exact" Is Not Always Exact

I also tested values outside the normal JSON-shaped input domain. The four probes in the table mean exactly this:

const sparseTags = { ...valid, tags: new Array(2) };
const inheritedFields = Object.create(valid);
const extraSymbol = { ...valid, [Symbol("extra")]: true };
const extraUndefined = { ...valid, extra: undefined };
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

The repository runs nine more probes, including non-enumerable properties and a throwing getter. Here is what happened for these four

Probe                             Native is-kit Runtypes Valibot Zod     ArkType typia  
Sparse tags array               Accept Reject Accept   Reject   Reject Reject   Accept
All fields inherited             Reject Reject Reject   Accept   Accept Accept   Accept
Extra symbol property             Accept Accept Reject   Accept   Accept Reject   Accept
Extra key with value undefined Reject Reject Reject   Reject   Reject Reject   Accept

All seven accepted the extra non-enumerable property I tested.

All seven threw when reading a getter that throws.

This does not mean one library is "correct" and the others are broken.

It means

"Exact object validation" needs a scope.

For example, my native guard uses

Object.keys(value);
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

That only sees enumerable string keys.

And this

value.tags.every(...)
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

skips sparse array holes.

That is perfectly reasonable for JSON data.

It is not a universal policy for every weird object JavaScript can create.

This is another reason I think validator comparisons should state the expected input domain.


📦 Bundle Size

Now the fun table. 😺

Each browser-targeted entry exports only validate.

All of them were bundled with the same esbuild settings:

  • browser platform
  • ESM
  • ES2022
  • minification
  • tree shaking
  • legal comments removed

Then I measured raw, gzip, and Brotli output.

Validator Raw bytes gzip bytes Brotli bytes
handwritten guard       588        337          295
is-kit         3,421      1,530        1,423
runtypes      16,667      5,188        4,657
valibot       5,722      1,853        1,675
zod          89,888     26,212       23,331
arktype     155,549     47,603       41,956
typia           772        403          339

A few important caveats.

This is standalone cost

If your app already ships Zod, the marginal cost of one more schema is not 26 KB gzip.

This table measures isolated entry bundles.

This is regular Zod

Not Zod Mini.

So this table does not answer

Valibot vs Zod Mini?

Generated does not mean zero bytes

typia's final guard bundle was only 772 bytes raw in this test.

But it still shipped generated code and a uint32 helper.

"Generated validator" does not mean "free validator".

Still, the architectural difference is very visible here.


⚡ Runtime

I almost did not include runtime numbers because validator benchmarks get misleading very quickly.
But since the workspace already had the same contract and fixtures, I ran a small steady-state benchmark.

Three cases:

  • valid user
  • wrong id
  • wrong nested profile.active

Each library ran in a separate Node process.

Each case used:

  • 20,000 warmup calls
  • 9 measured samples
  • 50,000 calls per sample

Schemas and validators were created outside the measured loop.

Results are median nanoseconds per call, with min–max in parentheses.

Validator                  Valid              Wrong id   Wrong profile.active
handwritten guard      79.9 (77.3–114.1)       73.6 (72.1–84.9)        80.8 (79.0–96.3)
is-kit     232.0 (225.7–257.5) 34.5 (33.1–38.9) 210.3 (203.6–218.1)
runtypes   5804.0 (5425.5–6298.0) 9221.8 (8443.2–11063.2) 10101.2 (9287.6–10503.5)
valibot      951.2 (425.2–1782.7)     200.2 (96.1–422.3)   1112.1 (648.4–1538.1)
zod         612.6 (351.3–1203.5) 1491.6 (862.9–2824.6) 2208.5 (1592.4–4757.4)
arktype      291.5 (278.1–614.4)           9.6 (6.6–17.7)         12.0 (11.3–37.6)
typia          21.9 (20.5–46.1)           2.0 (1.9–6.4)         11.5 (10.8–17.2)

Please do not read this as:

typia is the fastest validator.

This is a microbenchmark.

The same small object shapes are validated repeatedly, so V8 can optimize very aggressively.

I would not interpret a 2.0 ns result as a portable "cost of validation" in a real application.

The success APIs are also different:

  • handwritten guard uses a predicate
  • is-kit uses a predicate
  • Runtypes uses .guard()
  • Valibot uses is()
  • Zod uses safeParse(...).success
  • ArkType uses allows()
  • typia uses a generated equals guard

Those operations do not all perform exactly the same work.

What the benchmark does show is that architecture and failure path matter.

For example:

  • the handwritten guard pays for exact-key checks before rejecting the wrong id
  • ArkType's invalid paths were much cheaper than its valid path
  • typia's generated guard had very low steady-state overhead here
  • Runtypes' richer validation machinery costs more in this particular hot loop

That is useful.

A universal speed ranking is not.


So... Which One Would I Use? 🤔

After doing all of this, my decision tree is actually pretty simple.

Just one local narrowing check?

Use a handwritten guard.

if (isUser(input)) {
  // input is User
}
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

No dependency is a feature when the problem is small.

Lots of reusable guards?

This is where I use is-kit. 🔧

values.filter(isName);

if (isAge(value)) {
  // ...
}
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

The problem is not "I need a schema".

The problem is "I keep rebuilding the same predicates and composition helpers".

Want a validator object?

Runtypes is interesting when you like having one runtime object that can: 🏃

guard
check
parse
inspect
assert
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

Want schema validation with a modular API?

Valibot. 🤖

Especially if client bundle size and tree shaking are important parts of the design.

Want schemas tied into a bigger ecosystem?

Zod. 💎

That is probably its strongest reason to exist in many applications.

And if bundle size is a concern, evaluate Zod Mini separately.

Want the schema syntax to feel like TypeScript?

ArkType. 🚢

"18 <= number.integer <= 120";
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด ⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด

is a very different authoring experience from chained schema methods.

Want TypeScript types transformed into validators during the build?

typia.
But I would treat the build integration as part of the decision, not a footnote.


Final Thought

These tools overlap, but they live at different layers.

The hard part is not making seven validators compile. It is making seven validators mean the same thing.

So before comparing package size or benchmark screenshots, ask what the boundary needs to return and what runtime machinery is worth carrying.

Use the smallest model that still represents your boundary well.

Then, which one will you use?

Thank you for reading! 🤲

Top comments (29)

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

bro how you create these things hahahaha 😆🤣🤣🤣
you are SERIOUS DEV + FUNNY GUY 😆
by the way nice writing and your cover image 🫡

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

It’s my pleasure! 😸

I’m a funny frontend engineer!! 😹

And yep, I draw all my cover images by hand! 😹😹😹

Picked as gem
⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
pengeszikra profile image
Peter Vivo •

Draw by hand ....... strange!

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

your cover images are my favrioute ones 😆

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด Thread
 
nyaomaru profile image
nyaomaru •

Thx for kind words! I'll continue to draw them 😸

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

yup 😁

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
pengeszikra profile image
Peter Vivo •

This comparison with examples is very useful—thank you! I’ve saved the post it seems super informative, will be a help in deciding which one to choose!

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

I’m really happy the comparison was useful enough to save.

I hope it helps when you’re deciding which approach fits your project best! 🙌

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
shubhradev profile image
Shubhra Pokhariya •

Nice comparison, and JavaScript as the Magikarp on the cover got me 😄 The emoji test is the one I’d check against my database, since Postgres varchar(100) counts characters while JavaScript’s string.length counts UTF-16 code units, so the validator and column can disagree on the same string.

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

Haha, I’m glad you noticed the Magikarp 😹 Because it’s full of potential! 😹

And yes, that Postgres example is a perfect case where the validator and database can both be “correct” but still disagree on the same string. 👀

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
xulingfeng profile image
xulingfeng •

Full honesty, that cover art hooked me immediately, haha. I love Pokémon so much. Yellow, Gold and Silver are timeless; the GB era is still the best. The Switch entries feel like they lost a little spark somehow. My beloved Pikachu looks so weird these days, lol 😂

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

Haha, same here 😹

I really love Red / Green / Gold / Silver too. That GB era has such a special feeling.

Pikachu definitely looked cuter back then too 😹

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
xulingfeng profile image
xulingfeng •

Right?! The pixel‑art vibe is irreplaceable. Nothing beats GB‑era Pokémon 🎮

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด Thread
 
nyaomaru profile image
nyaomaru •

Pi-pi ka chu~! 😸⚡🎮

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด Thread
 
xulingfeng profile image
xulingfeng •

hahaha🤣🤣🤣

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
johnnylemonny profile image
𝗝𝗼𝗵𝗻 •

"The hardest part was not making seven validators compile. It was making seven validators mean the same thing." - this hits the nail on the head! 😸

Most benchmarks just throw a basic object at each library without aligning the actual runtime contract—like whether extra keys are stripped or rejected, how UTF-16 code units vs. code points are counted, or what happens with non-JSON edge cases like inherited properties and sparse arrays.

I also really like framing the choice around what you actually need at the boundary: if you just need a fast boolean narrowing check (isUser(input)), pulling in a full parsing and error-formatting schema engine is overkill, whereas form validation or public APIs genuinely need those rich error trees. Great, super thorough comparison, nyaomaru! 🚀

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

Thank you so much, John! 😸

That’s exactly what I wanted the comparison to show. 👍

And yes, I think the boundary should decide the tool.

If all I need is a boolean narrowing check, I’d rather keep it small.
If I need rich errors, parsing, transforms, or integrations, then a schema system starts earning its complexity.

Really glad that point came through! 🚀

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
alexshev profile image
Alex Shev •

Matching the runtime contract before comparing speed is important. UTF-16 length, unknown keys and coercion are easy places for validators to diverge while appearing to accept the same TypeScript type.

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

Exactly 😸

If the runtime contract isn’t aligned first, a speed comparison can end up measuring different behavior.

UTF-16 length, unknown keys, and coercion are all perfect examples of small semantic differences that can completely change the result. 👍

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
hordii_bohdan profile image
Bohdan Hordii •

Synthetic benchmarks rarely expose the real production bottleneck: the aggressive GC pressure triggered by recursive object allocation and unknown-key stripping on high-throughput ingest paths. While AOT-compiled approaches crush runtime parsers on raw ops/sec, you trade that speed for brittle build pipelines and restrictions inside hardened, zero-eval runtime environments. At 50k req/s, are you measuring schema parsing efficiency, or just watching V8 memory churn choke your event loop?

Bohdan Hordii // bohdanhordii.dev

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

That’s a fair criticism 😸

My benchmark is only measuring steady-state validation cost, not GC pressure or 50k req/s ingest behavior.

At that scale, allocation patterns, stripping/cloning behavior, and event-loop pressure could matter more than raw validator ops/sec.

And yes, AOT speed comes with a very real trade-off in build complexity and runtime constraints.

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
robertpaschal profile image
Odinaka Robert Nnamani •

Making seven validators mean the same thing is the real finding here. The Zod length check using code points instead of UTF-16 units is the kind of mismatch that only shows up in fixtures.

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

Exactly 😸

That Zod case was one of the best examples for me.

On paper the rule looked identical, but the fixture exposed a different runtime contract immediately.

That’s why I ended up trusting the shared fixtures more than the schema syntax. 👀

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
robertpaschal profile image
Odinaka Robert Nnamani •

That emoji fixture is the part that sticks. 51 emoji passing one length check and failing another is a clean way to show two schemas can look identical and still disagree.

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
technogamerz profile image
𝐓𝐡𝐞 𝐋𝐚𝐳𝐲 𝐆𝐢𝐫𝐥 •

The cover image is so good, wow! Op🔥🔥
I have no words. By the way, my entire focus was on Unknown. How long did it take you to make this? I mean, creating something like this in Photoshop with all those layers must take a lot of time.

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

Thank you so much! 😸🔥

Yeah, it takes quite a while, especially with all the layers. 😹

But I usually make these cover images as a little break, so I actually enjoy the process!

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
shikha_shukla_2469c41a94e profile image
Shikha Shukla •

new learning for me

⭐ Must Read: nyaomaru/i compared typescript validation libraries heres what surprised me g - สรุปดราม่าล่าสุด
 
nyaomaru profile image
nyaomaru •

I'm happy to hear that! 😸