๐ง The Problem
You've done everything right. Your User interface is clean, your API client returns Promise<User>, and tsc gives you a blinding green checkmark. Then production throws Cannot read properties of undefined (reading 'toLowerCase') and you spend an hour staring at a function that TypeScript swore was type-safe.
Here's the uncomfortable truth: TypeScript types are a compile-time fiction. They get erased the moment tsc finishes its job. At runtime, your beautifully typed User object is just... whatever JSON the server actually sent, which might be missing a field, have a null where you expected a string, or be an entirely different shape because someone shipped a backend change without telling you.
JSON.parse() returns any. Casting that to your interface with as User doesn't validate anything โ it just tells the compiler to stop complaining. You haven't closed the gap between "the type I wrote" and "the data I got." You've just told the type checker to look away.
๐ณ๏ธ Where the Gap Actually Shows Up
This is the pattern that breaks almost every app eventually:
typescript
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'member';
}
async function fetchUser(id: number): Promise {
const res = await fetch(/api/users/${id});
const data = await res.json(); // type: any
return data as User; // compiles, proves nothing
}
Nothing here checks that role is actually 'admin' or 'member' and not 'ADMIN' because someone changed the backend enum casing. Nothing checks that email exists at all. TypeScript trusts you completely, and you're trusting an HTTP response written by a different team, in a different repo, possibly changed an hour ago.
The fix isn't "write more interfaces." It's runtime validation at every trust boundary โ API responses, localStorage, env vars, webhook payloads, form data before it hits your business logic. Anything that enters your system from outside the compiler's view needs to prove its shape, not just claim it.
๐งช Three Ways to Close the Gap
Manual Type Guards
The no-dependency option. You write the checks yourself:
typescript
function isUser(data: unknown): data is User {
if (typeof data !== 'object' || data === null) return false;
const d = data as Record;
return (
typeof d.id === 'number' &&
typeof d.name === 'string' &&
typeof d.email === 'string' &&
(d.role === 'admin' || d.role === 'member')
);
}
const data = await res.json();
if (!isUser(data)) throw new Error('Invalid user payload');
// data is now safely typed as User
Trade-off: zero dependencies, fastest at runtime, but it doesn't scale. Nested objects, arrays of objects, optional fields, discriminated unions โ you end up reimplementing a validation library badly, one guard at a time, with no shared error reporting.
io-ts
The functional-programming approach, built on fp-ts. Your schema is your type:
typescript
import * as t from 'io-ts';
import { isRight } from 'fp-ts/Either';
const UserCodec = t.type({
id: t.number,
name: t.string,
email: t.string,
role: t.union([t.literal('admin'), t.literal('member')]),
});
type User = t.TypeOf;
const result = UserCodec.decode(data);
if (isRight(result)) {
const user: User = result.right;
} else {
// errors are PathReporter-friendly, but verbose to format
}
Trade-off: extremely powerful (codecs compose, you get encode and decode, great for bidirectional transforms), but the Either/fp-ts API is a real learning curve for teams not already doing FP. Error messages need extra tooling (PathReporter) to be human-readable. It's the right tool if your codebase already lives in fp-ts, and a steep tax if it doesn't.
Zod
The current default for a reason โ imperative-feeling API, no FP prerequisite:
typescript
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'member']),
});
type User = z.infer;
const data = await res.json();
const parsed = UserSchema.safeParse(data);
if (!parsed.success) {
console.error(parsed.error.flatten());
throw new Error('API contract violation');
}
const user: User = parsed.data;
Trade-off: .infer<> means you never hand-write the interface and the schema separately โ one source of truth. Error formatting is readable out of the box. The cost is bundle size (relevant if this runs client-side) and, as you'll see below, per-call overhead that's non-trivial at high volume.
โก Benchmarking the Overhead
Validation isn't free. I ran 1,000,000 parses of the User shape above (valid payloads, warm V8, Node 20, tinybench) to get a feel for relative cost:
text
Manual type guard ~38,000,000 ops/sec (baseline)
io-ts (decode) ~1,450,000 ops/sec (~26x slower than manual)
Zod (safeParse) ~2,100,000 ops/sec (~18x slower than manual)
as User (no check) ~โ ops/sec (and zero safety)
Numbers will vary with schema complexity, nesting depth, and Zod/io-ts versions โ treat these as directional, not gospel. The takeaway that does hold up: manual guards are an order of magnitude faster because they do exactly the checks you wrote and nothing else, while schema libraries pay for flexibility (composability, error aggregation, transforms, refinements).
For a single API response per HTTP request, none of this matters โ you're talking microseconds against a network round-trip measured in tens of milliseconds. It starts mattering when you're validating every item in a 10,000-row CSV import, every message in a high-throughput queue consumer, or every event in a hot WebSocket stream. In those cases, either validate once at the boundary and trust the data downstream, or drop to manual guards / a precompiled schema (Zod's upcoming compiled mode and alternatives like typebox, which compiles to JSON Schema and uses ajv under the hood, exist specifically for this).
๐ฏ Which One Should You Actually Use
- Manual guards: small, stable shapes, perf-critical hot paths, or you genuinely don't want a dependency.
-
io-ts: you're already in an
fp-tscodebase and want encode/decode symmetry. -
Zod: the pragmatic default for most apps โ API boundaries, form validation, env var parsing. Pair it with
typebox/ajvonly if profiling actually shows validation as a bottleneck, not preemptively.
The real lesson isn't "pick library X." It's that interface and type are promises, not guarantees, and every boundary where data crosses from "outside your program" to "inside your program" needs something that actually checks the promise instead of just declaring it.
What's your trust boundary story โ have you shipped a production bug that as SomeType let through, and what finally made you add real validation there?
Top comments (0)