Types vanish at runtime, validate at the boundary
The types stay behind in the build; at every boundary a runtime schema checks what really arrived, and the type is read off that schema.
Predict first
Before you read: predict
ExamHall proctor console. The live feed hands each alert's text to this banner helper. The file is built with tsc under strict: true; if the build passes, the built script is run:
What happens?
interface FeedAlert { seat: number; candidate: { name: string } } function banner(data: string): string { const raw: unknown = JSON.parse(data); const alert = raw as FeedAlert; // line 5 return `${alert.candidate.name}, seat ${alert.seat}`; // line 6} console.log(banner('{"seat":14,"candidate":"Ines"}'));The 30-second version
TypeScript's types exist only while tsc checks the code: the emitted JavaScript has none, so nothing checks what arrives while it runs. Data from the network, storage, the URL or a message is whatever the sender wrote. as only tells the compiler to believe you. Check such data with a runtime schema, and derive the type from that schema.
The types stay behind in the build; at every boundary a runtime schema checks what really arrived, and the type is read off that schema.
How it works
Step 1 of 11
tsc removes annotations, interfaces, type arguments and as; the emitted JavaScript is the same program without them.In the kitchen
- the drawings stay in the site office; the lorry brings only the load → types are erased; the running program has values only (step 1)
Analogy: A building site's goods-in gate
The architect's drawings say what each delivery should be; lorries bring whatever was loaded.
| On the site | In TypeScript |
|---|---|
| the drawings stay in the site office; the lorry brings only the load | types are erased; the running program has values only (step 1) |
| signing the supplier's delivery note without looking at the load | as on external data: the note is believed (step 3) |
| the wrong beam is found when the crane lifts it into place | the wrong shape fails where a field is used (step 2) |
| goods also come by the neighbouring site's crane, from phase-one leftovers in the yard, and on hand-written order slips | messages, storage and the URL are gates too (steps 5, 11) |
| the gate inspector measures every delivery against the checklist and writes down why one is refused | safeParse at the boundary, with its issues (step 7) |
| the tag fixed to an accepted delivery is copied from the checklist | the static type derived from the schema (step 8) |
| the supplier's yard checks its loads with the same checklist | one schema shared by sender and reader (step 10) |
In real terms: a type is a promise the compiler checks against your own code; a schema is code that checks a value when it arrives, and z.infer turns that check back into a type.
Breaks down when:
- An inspector checks a sample of the load. A schema checks the whole value, on every call.
- A delivery note is written by the supplier. An
asis written by you, about data you have not seen.
Contrasting case: a component the site casts itself from its own drawings. Values your own typed code builds are checked by tsc all the way; only data that crosses a boundary needs the gate.
Examples, run for real
4.1 ExamHall: the proctor console live feed
EXH-1550: the alert service began sending a tile's status as an object naming the proctor who paused the exam. The console cast each parsed message as StatusMsg, the build stayed green, and the tile read "Seat 12: [object Object]". MR !1750 checks each message with a StatusMsg schema from the shared package, derives the type from it, and rejects a message that fails, naming the field.
4.1 source | const msg = JSON.parse(data) as StatusMsg;
4.1 emitted | const msg = JSON.parse(data);
4.1 as StatusMsg | tsc: no diagnostics | node prints: Seat 12: [object Object]
4.1 StatusMsg schema | tsc: no diagnostics | node prints: rejected at status / Seat 12: paused
The emitted line has lost its as StatusMsg: nothing of the type survives the build (steps 1, 3). The wrong payload did not fail; it printed nonsense where the field was used (step 2). With the schema, the same payload is refused at the boundary, and a correct one is read as before (step 7).
4.2 Proof domain, catalog: one schema, one type
The catalog's product schema lives in a module that the catalog service and the product page both import. The page's type is read off it.
4.2 z.infer<typeof Product> | { sku: string; price: number; stock: number; }
4.2 with stock | tsc: no diagnostics | accepted, stock 3
4.2 stock missing | rejected: stock invalid_type
The type the checker derives lists exactly the three fields the schema checks (step 8). A response without stock is rejected with the field and the kind of failure, which a log can record without the payload (step 7).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A product page checks catalog responses with a schema. The catalog service stopped sending stock, so an MR removes it from the schema on line 3 and from the placeholder on line 8, and changes nothing else. strict: true.
Which lines does tsc report after the MR?
import { z } from "zod"; const Product = z.object({ sku: z.string(), price: z.number() }); // line 3: the MR removed stocktype Product = z.infer<typeof Product>; interface ProductRow { sku: string; price: number; stock: number } export const placeholder: Product = { sku: "", price: 0 }; // line 8: the MR removed stock export function badge(raw: unknown): string { const p: Product = Product.parse(raw); return p.stock > 0 ? "In stock" : "Sold out"; // line 12} export function row(raw: unknown): ProductRow { return Product.parse(raw) as ProductRow; // line 16}Question 2 of 5
A chat room shows who is typing. Its page script is built with tsc under strict: true; if the build passes, it runs in the page.
The chat service (Node, with the ws package; wss is its WebSocketServer) sends each notice like this:
wss.on("connection", (ws) => ws.send(JSON.stringify({ room: "r1", user: "ana" })));
What happens when the notice arrives?
import { z } from "zod"; const Typing = z.object({ room: z.string(), user: z.string() });type Typing = z.infer<typeof Typing>; declare const socket: WebSocket;const readTyping = (t: Typing) => `${t.user.toUpperCase()} is typing in ${t.room}`; socket.addEventListener("message", (e: MessageEvent<Typing>) => { console.log(readTyping(e.data)); // line 10});Question 3 of 5
The proctor console and its alert service share a schema package. An MR renames seat to seatId in schema.ts and changes no other file. The four files after the MR are compiled together with strict: true. schema.ts:
server.ts (the alert service):
import { Alert } from "./schema";
export function flag(seat: number): Alert {
return { seat, level: "high" };
}
tiles.ts (the console's candidate tiles):
import { Alert } from "./schema";
export function tile(raw: unknown): string {
const a = Alert.parse(raw);
return `Seat ${a.seat}`;
}
history.ts (the console's history panel):
import type { Alert } from "./schema";
interface HistoryAlert { seat: number; level: Alert["level"] }
export function row(raw: unknown): string {
const a = raw as HistoryAlert;
return `Seat ${a.seat} (${a.level})`;
}
Which files does tsc report?
import { z } from "zod"; export const Alert = z.object({ seatId: z.number(), level: z.enum(["low", "high"]) });export type Alert = z.infer<typeof Alert>;Question 4 of 5
A box office turns a checkout link into a total, at 30 per ticket and 1 to 8 tickets per order. strict: true. Built and run, it prints rejected rejected: even the link with qty=2 is refused.
Which single change makes it build and print 60 rejected?
import { z } from "zod"; const Order = z.object({ event: z.string(), qty: z.number().int().min(1).max(8) }); // line 3 export function total(search: string) { const r = Order.safeParse(Object.fromEntries(new URLSearchParams(search))); // line 6 return r.success ? r.data.qty * 30 : "rejected";} console.log(total("?event=e7&qty=2"), total("?event=e7&qty=20"));Question 5 of 5
A video player keeps its settings in localStorage. Version 2 turns subtitles from a language code into an object. A viewer whose settings version 1 saved, {"volume":0.8,"subtitles":"en"}, opens version 2, and the player crashes. strict: true.
Which change lets version 2 build and print subtitles EN for that viewer?
import { z } from "zod"; const Settings = z.object({ volume: z.number(), subtitles: z.object({ lang: z.string(), size: z.number() }) });type Settings = z.infer<typeof Settings>;const DEFAULTS: Settings = { volume: 1, subtitles: { lang: "en", size: 16 } }; function load(): Settings { const text = localStorage.getItem("player"); if (text === null) return DEFAULTS; return JSON.parse(text); // line 10} export function save(s: Settings) { localStorage.setItem("player", JSON.stringify(s)); // line 14} console.log(`subtitles ${load().subtitles.lang.toUpperCase()}`);Go deeper
Pro tips
- When a schema rejects input, log the issue's path and code, not the payload. A message such as
stock invalid_typelocates the fault and keeps customer or candidate data out of the logs (step 7). When: any boundary that can receive personal data. Verify:4.2 stock missing | rejected: stock invalid_type. Status: RAN. - Give a schema the business rules too, not only the types: ranges, lengths, allowed values. A price that is a number but negative is still wrong. When: writing any boundary schema. Verify: OWASP's Input Validation Cheat Sheet: "Semantic validation should enforce correctness of their values in the specific business context". Status: DOC.
Source
- TypeScript Handbook, "The Basics", section "Erased Types": https://www.typescriptlang.org/docs/handbook/2/basic-types.html. Quoted: "Most TypeScript-specific code gets erased away, and likewise, here our type annotations were completely erased." and "Remember: Type annotations never change the runtime behavior of your program." Supports steps 1–2. Valid as of 2026-09-30.
- TypeScript Handbook, "Everyday Types", section "Type Assertions": https://www.typescriptlang.org/docs/handbook/2/everyday-types.html. Quoted: "Reminder: Because type assertions are removed at compile-time, there is no runtime checking associated with a type assertion." Supports step 3. Valid as of 2026-09-30.
- Zod documentation, "Basic usage": https://zod.dev/basics. Quoted: "If it's valid, Zod returns a strongly-typed deep clone of the input."; "When validation fails, the .parse() method will throw a ZodError instance with granular information about the validation issues."; "Zod infers a static type from your schema definitions. You can extract this type with the z.infer<> utility". Supports steps 7–8. Valid as of 2026-09-30.
- Zod documentation, "Defining schemas", section "Coercion": https://zod.dev/api. Quoted: "To coerce input data to the appropriate type, use z.coerce instead". Valid as of 2026-09-30.
- MDN,
URLSearchParams.get(): https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/get. Quoted: "A string if the given search parameter is found; otherwise, null." Supports step 6. Valid as of 2026-09-30. - WHATWG WebSockets Standard: https://websockets.spec.whatwg.org/. Quoted: when "type indicates that the data is Text", the event's data is "a new DOMString containing data". MDN's
messageevent page: "The message event is fired when data is received through a WebSocket." Valid as of 2026-09-30. - OWASP, Input Validation Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html. Quoted: "Input validation should happen as early as possible in the data flow, preferably as soon as the data is received from the external party." and "Semantic validation should enforce correctness of their values in the specific business context". Supports step 5 and pro tip 2. Valid as of 2026-09-30.
Related lessons
Checked against the tools and docs as of 2026-09-30.