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; } 

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; }; }; 

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" ); }; 

And then

declare const input: unknown; if (isUser(input)) { input.name.toUpperCase(); } 

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; 

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); 

The important part is that the pieces remain normal guards

const names = values.filter(isName); if (isAge(value)) { // value is number } 

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); 

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); 

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; 

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); 

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 

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; 

I like this comparison

z.int().min(18).max(120); 

vs

"18 <= number.integer <= 120"; 

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"> & tagsSource:https://dev.to/nyaomaru/i-compared-6-typescript-validation-libraries-heres-what-surprised-me-325g

Related posts