skip to content

A team hand-writes `interface Order` and a separate `isOrder(x: unknown): x is Order` guard. Months later a required field is added to `Order`, the guard is never updated, and nothing fails to compile. Why does nothing break, and how would you stop this class of drift?

level: seniorimportance: should knowfreq 42%

answer

  1. two artefacts, no enforced link
  2. the guard keeps compiling
  3. hand-maintained pairs always drift
  4. derive the type from the schema
  5. make the key list a build error

basics

~20 s

Predicate bodies are never checked against the asserted type, so a guard silently keeps promising a shape it no longer verifies. The durable fix is one source of truth: derive the static type from a runtime schema so the check cannot fall behind the type.

solid answer

~40 s

The guard and the interface are two independent artefacts that only agree by convention. Because TypeScript verifies nothing about a predicate's body, adding a required field to `Order` produces no error in `isOrder` — the guard keeps returning true for payloads missing the new field, and every downstream consumer is told the field is guaranteed. The fix is to stop maintaining two things. With a schema library the runtime definition is primary and the type is derived: `const OrderSchema = z.object({ ... })` plus `type Order = z.infer<typeof OrderSchema>`, then parse at the boundary rather than guard. io-ts's `t.TypeOf` plays the same role. If you must keep hand-written guards, at least add a compile-time tripwire such as a `Record<keyof Order, true>` key map, which fails to compile the moment a key is added.

go deeper

for a junior

Know that a guard and the interface it asserts are separate code, and that changing one never forces a change to the other. Update both in the same edit.

for a middle

Explain the mechanism — the predicate body is unverified, so the guard keeps compiling and keeps lying — and show how deriving the type from a schema removes the second artefact entirely.

for a senior

Demonstrate the maintenance instinct: name the seams where compile-time and runtime descriptions are coupled by hand, add tripwires such as a keyof key map, and test guards on the rejection path rather than the happy path.

for a principal

Own the tradeoff across the codebase — which boundaries must parse, whether a validation dependency is worth its bundle and latency cost, and how contract-generated types change where runtime checking earns its keep.

## Why nothing breaks The interface and the guard have no relationship the compiler can enforce. ```ts interface Order { id: string; total: number; currency: string } // currency is new function isOrder(x: unknown): x is Order { if (typeof x !== 'object' || x === null) return false; const o = x as Record<string, unknown>; return typeof o.id === 'string' && typeof o.total === 'number'; // still compiles } ``` A predicate's body is not checked against the type it asserts — the only rule is that the asserted type be assignable to the parameter's declared type, and against `unknown` that rule is vacuous. So the guard compiles unchanged, keeps returning true for `{ id, total }`, and every consumer is now told `order.currency` is a `string` when it is `undefined`. The drift is not a bug in the guard; it is the absence of any mechanism that could have noticed. This is the general failure mode of hand-maintained runtime/compile-time pairs: a type, a validator, a database column, a serializer, a mock in a test fixture. Whichever ones the compiler does not connect will diverge, and the divergence is silent by construction. ## The durable fix: one definition, two outputs Invert the dependency. Make the **runtime** description primary — it is the only one that can exist at runtime anyway — and derive the static type from it: ```ts import { z } from 'zod'; const OrderSchema = z.object({ id: z.string(), total: z.number(), currency: z.string(), }); type Order = z.infer<typeof OrderSchema>; function handle(raw: unknown) { const order = OrderSchema.parse(raw); // throws on mismatch; order: Order } ``` Adding `currency` to the schema adds it to `Order` in the same edit, and the check comes along for free. There is no predicate to forget, because the validator *is* the definition. `safeParse` returns a discriminated result instead of throwing when you want to handle failure without exceptions. io-ts expresses the same idea with codecs and `t.TypeOf<typeof OrderCodec>`, returning an `Either` from `decode`. The conceptual shift matters more than the library: you stop *validating a value against a type you already claimed* and start *parsing untrusted input into a value whose type is now justified*. The parse call is the only place uncertainty exists; everything after it is genuinely typed. ## If you cannot adopt a schema library Sometimes the boundary types are few, or a dependency is unwelcome on a small client bundle. Then buy yourself a compile-time tripwire so the drift becomes a build error: ```ts const orderKeys: Record<keyof Order, true> = { id: true, total: true, // adding `currency` to Order breaks this line until it is listed }; function isOrder(x: unknown): x is Order { if (typeof x !== 'object' || x === null) return false; const o = x as Record<string, unknown>; return Object.keys(orderKeys).every((k) => k in o) && typeof o.id === 'string' && typeof o.total === 'number'; } ``` `Record<keyof Order, true>` requires an entry per key, so the object literal stops compiling the moment the interface grows. It does not force you to check the new field's *type* — nothing can do that by hand — but it converts a silent runtime hole into a red build, which is the whole game. Code generators that emit guards from declarations solve it more completely, at the cost of a build step. ## Testing what the compiler cannot Whichever route you take, guards deserve tests that assert *rejection*, not just acceptance. A test that feeds a valid payload passes for a guard that returns `true` unconditionally; a test that feeds a payload missing each required field in turn is the only one that catches a hollow check. If the guard is hand-written, that table of negative cases is the substitute for compiler verification, and it should be generated from the same key list where possible. ## The judgment being probed An interviewer asking this wants to hear that you know where types and runtime checks are *coupled by hand* and that you treat those seams as maintenance hazards rather than one-time work. The strong answer names the mechanism (predicate bodies are unverified), proposes the single-source-of-truth remedy, and is honest about its cost — a dependency, a bundle-size increase, and validation work on every request — versus the alternative of a hand-maintained pair that will drift the first week nobody is looking.

  • What exactly does `z.infer<typeof OrderSchema>` give you?
    The static type that corresponds to the runtime schema — the shape a successful `parse` produces. Because it is derived rather than written, the type cannot disagree with the validator: editing the schema changes both in one edit. It is the inverse of the hand-written pair, where an interface and a guard are only related by discipline.
  • How does `Record<keyof Order, true>` actually catch the drift?
    A mapped type over `keyof Order` requires an entry for every key, so the object literal fails to compile with "Property 'currency' is missing" the moment the interface grows. It cannot force you to validate the new field's type — nothing can — but it turns a silent hole into a build failure at the exact edit that opened it.
  • How would you test a hand-written guard so a hollow implementation is caught?
    Test the rejections. A guard that returns `true` unconditionally passes every happy-path test, so the meaningful suite removes or corrupts each required field in turn and asserts `false`. Driving those cases from the same key list that guards the interface keeps the negative table honest as the type grows.
  • What is the real cost of adopting a parse-at-the-boundary library?
    A runtime dependency and bundle weight on the client, plus validation work on every request — real on hot paths with large payloads. In exchange you delete a class of silent bugs and one hand-maintained artefact. The usual answer is to parse at genuine trust boundaries and trust generated contracts internally, rather than validating everywhere.

saying these in an interview costs you the question

  • Expects tsc to flag a guard that missed a new field
  • Thinks adding a field to an interface breaks its validator
  • Keeps interface and guard in sync by code review alone
  • Tests guards only with valid payloads
  • Believes structural typing makes runtime validation unnecessary

context