
TypeScript types disappear when your code runs. So anything that comes from outside your program (an API response, a form, a JSON file) needs a runtime check before you can trust it.
I wrote that check three ways: a handwritten type guard, Zod, and Valibot. Then I ran all three against the same 41 test inputs. By the end of this post you'll know:
why "max 100 characters" gave two different answers for the same string,
how one import line made a Zod bundle 5Γ bigger,
what each approach costs in bundle size and speed,
and which one I'd pick for which job.
Everything here comes from a repo you can clone and rerun. Every number below was produced by its scripts.
Flotrpy
/
validator-showdown
Same data, same rules, three TypeScript validators. A handwritten guard vs Zod vs Valibot, tested side by side with 30+ fixtures, pass/fail results, bundle-size comparison.
βοΈ Validator Showdown
Same data. Same rules. Three validators. One of them almost lets 102 characters through a 100-character limit.
A handwritten type guard vs Zod vs Valibot, run side by side on the same fixtures, with pass/fail results, bundle sizes, and a runtime benchmark.
The problem: TypeScript types vanish at runtime
TypeScript checks your code, not your data. This compiles without a single warning:
interface User {
name: string;
age: number;
}
const user = JSON.parse('{"name": 42, "age": "old"}') as User;
user.name.toUpperCase(); // π₯ TypeError at runtime
After compilation, interface User is gone. The JavaScript that actually runs has no idea what a User is. Anything that crosses a boundary (an API response, a form, a file, localStorage) is unknown until something checks it at runtime.
So I wrote that checkβ¦
The problem: TypeScript types vanish at runtime
This compiles with zero warnings:
interface User {
name: string;
age: number;
}
const user = JSON.parse('{"name": 42, "age": "old"}') as User;
user.name.toUpperCase(); // TypeError at runtime
The as User is a promise to the compiler, not a check. After compilation, interface User is gone. The JavaScript that runs has no idea what a User is.
The fix is a type guard: a function that checks the value at runtime and tells TypeScript what it found.
function isUser(input: unknown): input is User {
// runtime checks go here
}
if (isUser(data)) {
data.name; // TypeScript now knows this is a string
}
The contract all three validators must follow
interface User {
id: string;
name: string; // 1-100 UTF-16 code units
age: number; // integer, 18-120
role: "admin" | "user";
tags: string[];
profile: { bio: string; active: boolean };
}
The extra rules:
Extra keys are rejected, both at the top level and inside profile.
No coercion. The string "25" is not a valid age.
Input is plain JSON data: what JSON.parse gives you.
Every validator exports the same function: isUser(input: unknown): input is User.
The three contenders
- Handwritten guard: plain TypeScript, no library export function isUser(input: unknown): input is User { if (!isPlainObject(input) || !hasExactKeys(input, USER_KEYS)) return false;
return (
typeof input.id === "string" &&
isValidName(input.name) && // 1 <= name.length <= 100
isValidAge(input.age) && // Number.isInteger, 18-120
isValidRole(input.role) &&
isStringArray(input.tags) &&
isValidProfile(input.profile) // exact keys, bio string, active boolean
);
}
hasExactKeys uses Object.keys() and Object.hasOwn(). That's how it rejects extra keys.
It has no dependencies and nothing new to learn. The catch is that the User interface and the guard are two separate things, and only tests keep them in sync.
- Zod import * as z from "zod";
export const UserSchema = z.strictObject({
id: z.string(),
name: z.string().refine((name) => name.length >= 1 && name.length <= 100),
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 function isUser(input: unknown): input is User {
return UserSchema.safeParse(input).success;
}
z.strictObject rejects unknown keys. Here the schema is the source of truth, and z.infer gives you the type for free.
You might be wondering why name uses .refine() instead of .min(1).max(100). Hold that thought.
- Valibot 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 function isUser(input: unknown): input is User {
return v.is(UserSchema, input);
}
It's the same idea as Zod, but built from small functions you pipe together instead of chained methods. v.is() is a built-in type guard.
Small trick worth stealing: both schema files include a compile-time check, Expect, User>>. If a schema drifts away from the User type, tsc fails. I tested it by changing tags to numbers, and the build broke right away.
The surprise: how long is an emoji?
"Name must be at most 100 characters" sounds like one rule. It's really a question: what counts as a character?
JavaScript strings are stored as UTF-16, an encoding where most characters take one 16-bit "code unit". An emoji like π takes two. string.length counts those code units:
"a".length; // 1
"π".length; // 2
"π".repeat(51).length; // 102 <- over the limit
Array.from("π".repeat(51)).length; // 51 <- Unicode code points
My contract says 1-100 UTF-16 code units, so 51 emoji (102 units) must be rejected.

Here's what each length check did with it:
Bar chart: string.length counts 102 UTF-16 units and rejects the name; Zod's built-in max counts 51 code points and accepts it. A dashed line marks the limit of 100.
Zod's built-in z.string().max(100) counts Unicode code points, not UTF-16 code units. It sees 51, not 102, and lets the name through. Valibot's v.maxLength(100) and my handwritten guard both use string.length, see 102, and reject it.
Zod isn't wrong here. Counting code points is arguably closer to what a human means by "character". But it isn't my contract. If a database column or another service downstream counts UTF-16 units, those 51 emoji become a bug that only shows up in production.
*The fix is to say exactly what I mean:
*
name: z.string().refine((name) => name.length >= 1 && name.length <= 100),
With that, all three agree. Two fixtures guard this edge: name 50 emoji (exactly 100 units, valid) and name 51 emoji (102 units, invalid). One test also pins down Zod's built-in behavior, so I'll know if it ever changes.
Lesson: when two libraries have a rule with the same name, they don't always mean the same thing. Test the boundary with an emoji.
Test results: 123 out of 123
There are 41 fixtures in one shared file. Each has a name, an input, the expected answer, and a one-line reason:
{
name: "name 51 emoji",
group: "emoji",
input: userWith({ name: "π".repeat(51) }),
expected: false,
why: "51 emoji is only 51 code points but 102 UTF-16 code units, over the limit.",
}
They cover valid users, age boundaries (17, 18, 120, 121, 18.5), name boundaries (0, 1, 100, 101 characters), the emoji cases, wrong types, missing fields, bad roles, bad tags, extra keys (including a sneaky proto key created by JSON.parse), and non-objects like null and arrays.

npm run compare runs every fixture through every validator and prints a color-coded table:
| Fixture | Expected | Handwritten | Zod | Valibot | Note |
| name 50 emoji | valid | PASS | PASS | PASS | |
| name 51 emoji | invalid | PASS | PASS | PASS | |
| age as string | invalid | PASS | PASS | PASS | |
| extra proto key | invalid | PASS | PASS | PASS | |
...
123/123 checks passed (41 fixtures x 3 validators)
No disagreements: all validators gave the same answer on every fixture.
Group Fixtures Handwritten Zod Valibot
valid 3 β
3/3 β
3/3 β
3/3
age 5 β
5/5 β
5/5 β
5/5
name 4 β
4/4 β
4/4 β
4/4
emoji 2 β
2/2 β
2/2 β
2/2
types 7 β
7/7 β
7/7 β
7/7
missing 4 β
4/4 β
4/4 β
4/4
role 3 β
3/3 β
3/3 β
3/3
tags 3 β
3/3 β
3/3 β
3/3
extra-keys 4 β
4/4 β
4/4 β
4/4
non-object 6 β
6/6 β
6/6 β
6/6
I tested the table itself too. When I swapped Zod back to the built-in .max(100), the script printed 122/123 checks passed, flagged the name 51 emoji row as DISAGREE, and exited with an error. The safety net works.
On top of the fixtures, Vitest (the test runner) runs 478 tests in six files. That includes a check that no validator mutates its input: every input stays deep-equal after validation and still works when deeply frozen.
Bundle size: what does one isUser cost?
I bundled each validator on its own with esbuild for the browser as ESM (ECMAScript modules, the standard import/export format), minified it, then compressed it with gzip and Brotli:
Bar chart of gzipped bundle size: Handwritten 434 B, Zod 26.5 kB, Valibot 1.9 kB.
Validator Minified Gzip Brotli
Handwritten 832 B 434 B 379 B
Zod 90.6 kB 26.5 kB 23.5 kB
Valibot 5.8 kB 1.9 kB 1.7 kB
This is standalone cost only. Each row is one schema and nothing else. In a real app you pay for the library once and every extra schema is cheap, so don't read this as "Zod costs 26 kB per form".

The import line that cost 364 kB
My first measurement had Zod at 454.3 kB minified. The culprit was one line:
import { z } from "zod"; // pulls in everything
import * as z from "zod"; // what the Zod docs recommend
Bar chart: import { z } from "zod" bundles to 454.3 kB minified; import * as z from "zod" bundles to 90.6 kB.
Same schema, same build settings, 5Γ smaller. The namespace import lets esbuild tree-shake Zod, meaning it drops the parts my schema never uses. Both measurements are in the repo's git history.
Runtime benchmark
npm run bench calls each isUser in a tight loop. It warms up first so V8's JIT (just-in-time compiler) can optimize the code, then takes 25 samples of 20,000 calls and reports the median nanoseconds per call:
Two bar charts on the same scale. Valid input: Handwritten 172.2 ns, Zod 612.3 ns, Valibot 1,435.3 ns. Invalid input: Handwritten 79.4 ns, Zod 1,635 ns, Valibot 765.6 ns.
Validator Valid input Invalid input
Handwritten 172.2 ns 79.4 ns
Zod 612.3 ns 1,635 ns
Valibot 1,435.3 ns 765.6 ns
What I noticed:
The handwritten guard won both. That makes sense, since it does nothing extra.
Zod was faster than Valibot on valid input but slower on invalid input. My guess is that safeParse builds a detailed error object even when all I want is true or false.
Smallest bundle β fastest runtime. Valibot had the smallest library bundle but the slowest valid-input time.
β οΈ This is a microbenchmark, not a universal ranking. It's one small schema, on one machine (Node 22, Linux), with tiny inputs. CPU, JIT warmup, input shape, and library versions all move these numbers. Every result is under 2 microseconds per call, which is fast enough for almost any app. Run it on your own machine before you believe it.
One more difference: inherited keys
All three agree on JSON data. Outside that domain, they don't.
const input = Object.assign(Object.create({ inherited: true }), validUser());
This object has an extra key on its prototype, which JSON.parse can never produce. My guard accepts it because Object.keys() only sees own keys. Zod and Valibot reject it because they walk keys with for...in, which includes inherited ones.
Neither is wrong for JSON input. But "strict object" doesn't mean exactly the same thing in every library, and I only know because I wrote a test for it.
You Try it:
git clone https://github.com/Flotrpy/validator-showdown.git
cd validator-showdown
npm install
npm run verify
npm run verify runs lint, type checking, all tests, and the comparison table. CI (continuous integration, which runs the checks automatically on every push) runs it on Windows, macOS, and Linux with Node 20, 22, and 24.

What I learned
A rule with the same name isn't always the same rule. One fixture with 51 emoji showed me that.
Read your library's definition of "length". Code points and UTF-16 code units are different contracts.
How you import matters as much as what you import. One line was the difference between 454 kB and 91 kB.
Small bundle and fast runtime are different goals. Measure both.
Generated docs stay honest. The results, bundle, and benchmark tables in the repo's README are written by scripts, and CI fails if they go stale.
Which validator do you reach for, and have you ever been bitten by a string length check? Tell me in the comments. I read every one.
Top comments (0)