skip to content

A service validates API responses with `function isOrder(x: unknown): x is Order { return typeof x === 'object' && x !== null && 'id' in x; }`, where `Order` is `{ id: string; items: Item[]; total: number }`. Which bad payloads still pass this guard, and how deep does a trustworthy check have to go?

level: middleimportance: must knowfreq 58%

answer

  1. three clauses, a dozen claims
  2. typeof object is true for arrays
  3. in proves presence, not type
  4. arrays need element-wise checks
  5. structural typing tolerates extras

basics

~20 s

The guard only proves the value is a non-null object with some property named id. Arrays pass, id may be a number, and items and total are never examined — so a trustworthy check must test every declared field, recursively, and array elements one by one.

solid answer

~50 s

That guard establishes three facts and claims a dozen. `typeof x === 'object' && x !== null` rules out primitives and `null` but still admits arrays and any random object. `'id' in x` proves a property with that name exists; it says nothing about its type, so `{ id: 42 }` passes and every downstream `order.id.toUpperCase()` throws. `items` and `total` are not looked at at all, so a payload missing them is narrowed to a type that guarantees them. A check that earns the predicate has to test each declared property with `typeof`, descend into nested objects, and validate arrays element-wise with something like `Array.isArray(v.items) && v.items.every(isItem)`. Extra properties are fine to ignore — structural typing tolerates them — but every property the type *promises* must be verified, or the guard is just a cast.

go deeper

for a junior

Know that typeof x === 'object' is true for arrays and for null, and that checking a property exists is not the same as checking its type. Read a guard and say which fields it never looks at.

for a middle

Walk the guard clause by clause and enumerate the payloads that slip through, then state the depth rule: every declared property, recursively, with arrays checked element-wise via a composed guard.

for a senior

Show judgment about what the check is worth: where the boundary sits, why NaN and JSON dates defeat naive checks, and when hand-written depth should give way to a schema that generates both the check and the type.

for a principal

Own the standard across services — which boundaries must validate, what a guard is allowed to assume about a trusted internal caller, and the cost of deep validation on hot paths versus the cost of a bad value reaching storage.

## Reading the guard clause by clause ```ts interface Item { sku: string; qty: number } interface Order { id: string; items: Item[]; total: number } function isOrder(x: unknown): x is Order { return typeof x === 'object' && x !== null && 'id' in x; } ``` - `typeof x === 'object'` — true for every object *and* for `null`, which is why the next clause exists. It is also true for arrays, `Date`, `Map`, and a `Response`. - `x !== null` — genuinely useful; without it the guard admits `null` and the very first property read throws. - `'id' in x` — proves only that a property of that name is present. After this narrowing the checker knows `x` has an `id` of type `unknown`; it has learned nothing about whether that value is a string. So the guard proves: *not a primitive, not null, has an `id`*. The predicate claims: *has a string `id`, an array of well-formed `Item`s, and a numeric `total`*. The gap between those two sentences is the bug surface. ## What still gets through ```ts isOrder([]); // no — no 'id' isOrder({ id: 42 }); // yes — id is a number isOrder({ id: 'a' }); // yes — items and total are missing isOrder({ id: 'a', items: 'none', total: 'free' }); // yes — wrong types throughout isOrder({ id: 'a', items: [1, 2], total: NaN }); // yes — junk elements ``` Each of those narrows to `Order`, and from that point the compiler actively helps the bug travel: it will not complain about `order.items.map(...)`, `order.total.toFixed(2)`, or passing the value into a function that requires a valid order. The failure appears in a module that never called the guard. ## How deep is deep enough The rule is simple to state: **verify every guarantee the asserted type makes, and nothing more.** 1. **Every declared property, with its type.** `typeof v.id === 'string'`, `typeof v.total === 'number'`. 2. **Arrays element-wise.** `Array.isArray(v.items)` proves it is an array of *something*; `v.items.every(isItem)` is what makes it an array of `Item`. 3. **Nested objects recursively**, by composing the smaller guards rather than re-checking inline. 4. **Optional versus missing.** If a field is optional, `undefined` must be allowed; if it is required, absence must fail. Under `exactOptionalPropertyTypes` an explicit `undefined` and a missing key are different, and your check should match the declaration. 5. **Values the type cannot express.** `typeof NaN === 'number'` is true, and JSON has no date type, so a field declared `Date` arrives as a string and will never satisfy `instanceof Date`. If your type says `Date`, the guard must convert, not just check. What you do *not* need is an extra-property check. TypeScript is structurally typed, and a value with additional keys genuinely is an `Order` as far as the type system is concerned. Excess-property checks exist for object literals at compile time; they are not a runtime concern. A guard that meets the bar looks like this: ```ts function isItem(x: unknown): x is Item { return typeof x === 'object' && x !== null && typeof (x as Item).sku === 'string' && typeof (x as Item).qty === 'number'; } 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' && Array.isArray(o.items) && o.items.every(isItem); } ``` Note the `as` inside: the guard body is where casts are unavoidable, because you are reading properties off a value whose shape is not yet established. That is precisely why the body deserves review and tests — it is the one place the safety of everything downstream is decided. ## The economics Hand-written deep guards are correct but tedious, and they rot: nothing forces the guard to change when the interface gains a field. For a couple of small boundary types this is fine. Once the shapes are wide, nested, or numerous, the check and the type should be generated from a single schema definition, so the depth is free and the drift is impossible. The judgment an interviewer is testing here is whether you know how far a check must go — and therefore how much you are really buying when you write three clauses and call it validation.

  • What does the checker actually know about `x` after `'id' in x` succeeds?
    Only that a property named `id` exists on it. The narrowing gives you the property at type `unknown`, so reading it is safe but doing anything with it is not — `{ id: 42 }` passes. To learn the type you have to test it: `typeof o.id === 'string'`. Presence and type are separate facts, and `in` establishes only the first.
  • Does the guard need to reject an object that carries extra properties?
    No. TypeScript is structurally typed, so a value with additional keys satisfies the type. Excess-property checking is a compile-time courtesy for object literals only. Rejecting extras at runtime is a security or storage decision — stripping unknown keys before persisting, say — not a requirement of the type.
  • A field is declared `Date` but the payload came from JSON. What must the guard do?
    JSON has no date type, so the field arrives as a string and `instanceof Date` is always false — a guard asserting `Date` can never honestly succeed. Either declare the wire type with `string` and convert in a separate mapping step, or have the boundary parse and construct a real `Date`, validating that the result is not `NaN`.
  • Is `Array.isArray(v.items)` enough for a field typed `Item[]`?
    No. It proves the value is an array, not that its elements are `Item`s, so `[1, 2, 3]` and `[null]` both pass and the first `item.sku` throws. Compose the element guard: `Array.isArray(v.items) && v.items.every(isItem)`. Empty arrays pass trivially, which is correct — the type permits them.

saying these in an interview costs you the question

  • Thinks typeof x === 'object' excludes arrays
  • Treats 'id' in x as proof that id is a string
  • Checks the array but never its elements
  • Adds extra-property rejection but skips nested fields
  • Assumes a Date field survives JSON.parse as a Date

context