I've shipped this bug. A backend quietly dropped a field, the frontend still compiled and rendered fine in CI, and it blew up in production the moment a real account without that field loaded. You've probably lived some version of it. Here's exactly why it happens and how to stop it.

The two-minute version
Types are erased before your code runs. tsc reads your annotations, checks them against each other, then deletes every last one and emits plain JavaScript. At runtime there is no User type, no string, no number guarantee. There is only whatever your API, your JSON.parse, your environment variables, and your database actually handed you.
That means a type is a promise you make to the compiler. It is not a promise the outside world keeps.
Watch it crash
Here is a User and a value that "is" a User:
interface User {
id: number;
name: string;
email: string;
}
// This actually came from an API that returned { id: 1, name: "Ada" }
// (no email field, a backend change nobody told the frontend about)
const raw: unknown = JSON.parse('{"id":1,"name":"Ada"}');
const user = raw as User; // compiler: "looks good to me"
console.log(user.email.toLowerCase()); // 💥
Run tsc --strict --noEmit on that. It passes. Zero errors. Then run it:
TypeError: Cannot read properties of undefined (reading 'toLowerCase')
The as User is the trap. A type assertion doesn't check anything. It's you telling the compiler "trust me, stop looking," and the compiler happily obeys. The moment the real data disagrees with your assertion, you get a runtime crash with a stack trace pointing at the symptom, three functions away from the actual lie.
Every one of these is the same gotcha wearing a different hat:
const data = await res.json():res.json()returnsPromise<any>, andanyis a hole in the type system that swallows every check downstream.JSON.parse(...): alsoany. Same hole.process.env.PORT: typedstring | undefined, but peopleNumber(...)it or slap a!on it and forget it can be missing (hello,NaN).arr[i]: this one is worse, because it lies by default.
The one that lies by default
const names: string[] = ["Ada", "Alan"];
const third = names[2]; // TypeScript says: string
console.log(third.toUpperCase()); // undefined at runtime → 💥
names[2] is typed string. At runtime it is undefined. TypeScript, by default, assumes every array index is populated, which is optimistic to the point of being wrong. Turn on one flag:
// tsconfig.json
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
Now the same line becomes a compile error:
error TS18048: 'third' is possibly 'undefined'.
The bug moved from your production logs to your editor. That is the whole game: pull the failure earlier in time, from a paged 2am incident to a red squiggle you fix before you commit.
The fix: validate at the boundary
The mental model that fixes this permanently: trust types inside your program, never at its edges. Every place data enters from the outside world (network, disk, JSON.parse, env, form input) is a boundary, and a boundary needs a runtime check, not a compile-time assertion.
You don't need a library for this. A type guard is a plain function that returns a special boolean:
interface User { id: number; name: string; email: string; }
function isUser(v: unknown): v is User {
return (
typeof v === "object" && v !== null &&
typeof (v as any).id === "number" &&
typeof (v as any).name === "string" &&
typeof (v as any).email === "string"
);
}
const raw: unknown = JSON.parse('{"id":1,"name":"Ada"}'); // no email
if (!isUser(raw)) {
throw new Error("API returned a shape we don't trust");
}
// Past this line, `raw` is a real User: checked, not asserted.
console.log(raw.email.toLowerCase());
Now the failure happens at the boundary, with a message that names the actual problem ("API returned a shape we don't trust"), instead of a TypeError deep inside a render function. The v is User return type tells the compiler that inside the if, the value is narrowed to User. No as needed after the guard, because you earned the type instead of asserting it.
For anything bigger than a couple of fields, reach for a schema validator like Zod or Valibot. They generate both the runtime check and the static type from one definition, so the two can't drift apart:
import { z } from "zod";
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof User>;
// throws a precise, field-level error if the API lied
const user = User.parse(await res.json());
One source of truth. The static type and the runtime guard are the same object.
The takeaway
TypeScript is a compile-time tool doing a compile-time job extremely well. It was never going to check the network for you, because the network doesn't exist when it runs. The gotcha is not a TypeScript flaw. It is a mismatch between where you think the checking happens and where it actually happens.
Three moves close the gap for good:
- Ban
ason external data. If it came from outside your program, assert nothing. - Turn on
noUncheckedIndexedAccess(andstrict, if you somehow still have not). - Validate every boundary with a type guard or a schema, so the type you trust inside is the shape you verified at the edge.
Do that, and the class of bug that typechecks clean and crashes in production stops existing.
I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].
Get the next one in your inbox → subscribe at astraedus.dev.