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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
};
Enter fullscreen mode Exit fullscreen mode

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"
  );
};
Enter fullscreen mode Exit fullscreen mode

And then

declare const input: unknown;

if (isUser(input)) {
  input.name.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The important part is that the pieces remain normal guards

const names = values.filter(isName);

if (isAge(value)) {
  // value is number
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

I like this comparison

z.int().min(18).max(120);
Enter fullscreen mode Exit fullscreen mode

vs

"18 <= number.integer <= 120";
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

So with our contract

maximum = 100 UTF-16 code units
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

That only sees enumerable string keys.

And this

value.tags.every(...)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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 (54)

Collapse
 
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 ๐Ÿซก

Collapse
 
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
Collapse
 
pengeszikra profile image
Peter Vivo •

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

Collapse
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

Drawing by hands!!!!
My respect for nyaomaru day by day: ๐Ÿ“ˆ๐Ÿ“ˆ๐Ÿ“ˆ๐Ÿ“ˆ๐Ÿ“ˆ๐Ÿ“ˆ๐Ÿ“ˆ
lol...

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

7year plus experience too...

Thread Thread
 
nyaomaru profile image
nyaomaru •

Haha, thank you!! ๐Ÿ˜น๐Ÿ˜น๐Ÿ˜น
My drawing skill is slowly leveling up too! ๐Ÿ“ˆ๐ŸŽจ๐Ÿ˜ธ

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

๐Ÿ™Œ๐Ÿ˜Ž ngl they are funny and engaging

Thread Thread
 
nyaomaru profile image
nyaomaru •

Thx ๐Ÿ˜ธ๐Ÿ’ช

Thread Thread
 
puneetkumar2010 profile image
Puneet Kumar Mishra •

๐Ÿ™Œ๐Ÿ˜Ž

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

your cover images are my favrioute ones ๐Ÿ˜†

Thread Thread
 
nyaomaru profile image
nyaomaru •

Thx for kind words! I'll continue to draw them ๐Ÿ˜ธ

Thread Thread
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

yup ๐Ÿ˜

Collapse
 
luke_bennett_ed52405c798e profile image
Comment marked as low quality/non-constructive by the community. View Code of Conduct
Luke Bennett • • Edited

Check your email no problem xxx soon xxxx

Collapse
 
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!

Collapse
 
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! ๐Ÿ™Œ

Collapse
 
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.

Collapse
 
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. ๐Ÿ‘€

Collapse
 
mohsen_jr profile image
Mohsen jr •

The build setup is an interesting trade-off with typia. Saving runtime overhead is great, but in a team project, slower builds and extra tooling can affect the whole development workflow, not just validation speed.

Collapse
 
nyaomaru profile image
nyaomaru •

Yeah, thatโ€™s the trade-off ๐Ÿ˜ธ

typiaโ€™s runtime performance is impressive, but the build setup becomes part of the cost too.

In a team project, that affects everyoneโ€™s workflow, not just the validation path. ๐Ÿ˜บ

Collapse
 
stackmasteraliza profile image
Aliza Ali •

"Ask what the boundary needs to return" is the right first question. Two things worth adding: Zod, Valibot and ArkType all implement Standard Schema now, so tools that accept it (tRPC, TanStack Form and others) don't lock you into one validator, and you can genuinely pick per boundary. And the ArkType precompile point matters in practice: under a strict CSP without unsafe-eval, or on runtimes that block new Function, you're on the fallback path, so it's worth benchmarking in the environment you actually deploy to. Great comparison!

Collapse
 
nyaomaru profile image
nyaomaru •

Really good additions ๐Ÿ˜ธ

Standard Schema definitely makes the โ€œpick per boundaryโ€ idea much more practical.

And yes, the ArkType CSP / new Function point is important too. Benchmarking the actual deployment path matters more than the ideal fast path.

Thanks for adding this! ๐Ÿ™Œ

Collapse
 
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 ๐Ÿ˜‚

Collapse
 
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 ๐Ÿ˜น

Collapse
 
xulingfeng profile image
xulingfeng •

Right?! The pixelโ€‘art vibe is irreplaceable. Nothing beats GBโ€‘era Pokรฉmon ๐ŸŽฎ

Thread Thread
 
nyaomaru profile image
nyaomaru •

Pi-pi ka chu~! ๐Ÿ˜ธโšก๐ŸŽฎ

Thread Thread
 
xulingfeng profile image
xulingfeng •

hahaha๐Ÿคฃ๐Ÿคฃ๐Ÿคฃ

Collapse
 
idanshalem profile image
Idan Shalem •

For a React form, this suggests keeping the input counter and schema on one shared counting rule, rather than mixing value.length, HTML maxLength, and schema validation.

For code points, matching Zod 4.6.5 in your comparison:

const count = (value: string) => [...value].length;

The component can display count(value)/100 and show the validation error without truncating the controlled value in onChange, which can interfere with IME composition.

If the product means user-perceived characters instead, use Intl.Segmenter with granularity: 'grapheme' for the counter and the schema's custom refinement. Otherwise a family emoji can still count differently in the UI and validation.

Collapse
 
nyaomaru profile image
nyaomaru •

Good point! ๐Ÿ˜ธ

I especially like the idea of sharing the same counting rule between the counter and the schema. Otherwise length, maxLength, and validation can quietly mean different things.

And the IME point is important too. For Japanese, Chinese, Korean, and other composition-based input, Iโ€™d rather avoid truncating the controlled value inside onChange.

Intl.Segmenter is also a nice reminder that even โ€œcharacter countโ€ needs a definition. ๐Ÿ˜น

Collapse
 
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! ๐Ÿš€

Collapse
 
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! ๐Ÿš€

Collapse
 
emilywoodsnyc profile image
Emily Woods •

The schema with transforms caveat is the bit Iโ€™d turn into a separate fixture. Iโ€™d feed in a value that parses successfully but changes type, then check that the original input is still kept separate from the parsed output. Itโ€™s easy to focus on which values get rejected and miss what a successful parse actually returns.

Collapse
 
nyaomaru profile image
nyaomaru •

Ahh, I see ๐Ÿ˜ธ

Thatโ€™s a good point. I was mostly looking at accepted/rejected behavior, but what comes back after a successful parse matters too.

Definitely something worth checking separately. ๐Ÿ‘€

Collapse
 
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.

Collapse
 
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. ๐Ÿ‘

Some comments may only be visible to logged-in visitors. Sign in to view all comments.