Type Erasure & the Runtime Boundary

Advanced · 20 min read · ▶ live playground · ✦ checkpoint

This may be the most important lesson in the course. You've known since 1.5 that types are erased from the compiled output — now we follow that fact to its uncomfortable conclusion: your types are a description of the data you expect, and nothing anywhere enforces them on the data you actually receive. Every TypeScript incident report that ends in "but the type said…" traces back to this lesson.

Types vanish — so what's guarding the door?

Inside your own code, the answer is: the checker, completely. If every value is born in your program, every value was checked at compile time, and erasure costs you nothing.

But real programs eat data from outside — and outside has never heard of your types:

type User = {
  name: string;
  karma: number;
};
 
// pretend this arrived from an API…
const payload = '{"name": "Ada", "karma": "not-a-number"}';
 
const user: User = JSON.parse(payload);
console.log("karma:", user.karma);

→ karma: not-a-number — it compiled clean, and user.karma is a string wearing a number name tag. No error. Yet.

That compiled clean. Why didn't the checker object to a string in karma?

The bill arrives later, far from the crime scene:

type User = { name: string; karma: number };
const user: User = JSON.parse('{"name": "Ada", "karma": "oops"}');
 
// …200 lines and three files later:
console.log(user.karma.toFixed(1));

→ TypeError: user.karma.toFixed is not a function — a runtime crash, in code the checker had proven correct. The types were right; the data was wrong.

as doesn't check anything either

The other costume shop is the type assertion. value as User tells the checker "trust me, I know what this is" — it changes the checker's belief and does absolutely nothing at runtime (it erases, like every type):

type User = { name: string; karma: number };
 
const raw: unknown = JSON.parse('{"totally": "wrong"}');
const user = raw as User; // an assertion: a DECLARATION, not a validation
 
console.log("name is:", user.name); // checker satisfied; data says otherwise

→ name is: undefined — as compiled to nothing. It's you overriding the checker, and you were wrong.

Assertions have legitimate uses — you'll meet honest ones in 13.3 — but hold the rule firmly: as is you informing the compiler, never the compiler checking you. If the information is wrong, you've hand-delivered a lie into the one system whose job was catching lies.

Map your boundaries

So where does data enter from outside the checker's jurisdiction? Every app has the same short list — worth literally sketching for yours:

  • The network — API responses, webhooks. (fetch + JSON.parse: the big one.)
  • StoragelocalStorage, files, databases without typed clients.
  • User input — forms, URLs, query params.
  • The environment — env vars, config files, CLI arguments.

Everything inside the line, the checker guards for free, forever. Every arrow crossing the line needs a guard you write. That's the whole discipline: types inside, validation at the door.

Honest boundaries: unknown in, validated types out

The honest pattern replaces both costumes. Type the incoming value unknown — the type that permits nothing until you prove something — then narrow it with real runtime checks (the same narrowing you learned in 4.2, aimed at data instead of unions):

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

Study what moved. The bad payload still arrives — but now it fails at the boundary, loudly, with the evidence attached, instead of detonating three files later as a mystery TypeError. Inside parseUser's happy path, the User type is no longer a costume: it's a receipt for a check that actually ran.

Hand-writing isUser for every shape gets old fast, and that's the right instinct: next lesson, the Zod library generates the validator and the type from one schema — same boundary discipline, a tenth of the code. The discipline is this lesson; 13.2 is the ergonomics.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion