← Dev log

frontend

TypeScript types vanish at runtime; Zod doesn't

TypeScript types vanish at runtime; Zod doesn't

TypeScript types are erased at compile time — they exist for the compiler and for you while writing code, and enforce nothing at runtime. Anything crossing a real boundary — an API response, a WebSocket message, localStorage, user input — can be anything at runtime regardless of the declared type. A response typed Token[] on the frontend is really "whatever the network happened to deliver," trusted entirely on faith unless something actually checks it.

Zod defines a schema once and gets both a runtime validator and a derived TypeScript type from the same source, so the two can't drift the way a hand-written interface and a separately hand-written check drift.

const TokenSchema = z.object({
  id: z.string().uuid(),
  number: z.number().int(),
  status: z.enum(["queued", "called", "serving", "completed", "cancelled"]),
  priority: z.number().default(0),
});

type Token = z.infer<typeof TokenSchema>;

const result = TokenSchema.safeParse(incomingData);
if (!result.success) {
  // handle malformed data explicitly, instead of silently trusting it
}

Every WebSocket message is exactly the kind of untyped-at-runtime boundary this is for — a malformed payload from a backend bug, a version mismatch between an older kiosk build and a newer backend, a corrupted message. Blindly trusted, any of those could crash the frontend or silently corrupt local state in a way that's hard to trace back. Validating at the boundary, before it reaches application logic or a store, turns a confusing "why is the UI broken" hunt into a loud, immediately traceable error at the exact point the bad data entered.

Form validation is the more common day-to-day use — device activation codes, admin fields, login credentials — with the schema doubling as both the validation logic and the source of truth for a valid shape, so the rules live in one place instead of duplicated between a form component and a submit handler.

The real value is one schema instead of two representations of the same thing. The alternative is a hand-written interface plus a separate hand-written runtime check that have to be kept in sync by hand and reliably drift the moment someone updates one and forgets the other.