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 🤔

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

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