Narrowing & Type Guards
How control flow narrows a union down to one member — typeof and instanceof checks, the in operator, discriminated unions, user-defined type guards, assertion functions, and never-based exhaustiveness.
12 questions
JuniorTheoryVery commonWhat makes a union discriminated, and how do you narrow one?
What makes a union discriminated, and how do you narrow one?
Every member declares the same field with a different literal type — the discriminant, e.g. kind: 'circle' versus kind: 'square'. Testing that field in an if or a switch narrows the whole object to one member, including the fields unique to it.
Common mistakes
- ✗Typing the discriminant as
stringinstead of a literal type, which kills narrowing - ✗Believing narrowing works in a
switchbut not in anif - ✗Expecting only the discriminant to narrow rather than the whole object
Follow-up questions
- →Why does typing
kind: stringstop the union from narrowing at all? - →How would you make the compiler reject an unhandled member?
JuniorTheoryVery commonHow do ?. and ?? behave on null versus 0 or an empty string?
How do ?. and ?? behave on null versus 0 or an empty string?
?. short-circuits: when the left side is null or undefined the whole access yields undefined instead of throwing. ?? supplies a fallback only for those two values — unlike || it leaves 0, '' and false alone.
Common mistakes
- ✗Treating
??as a synonym for||and losing a legitimate0or empty string - ✗Expecting
a?.bto evaluate tonullrather than toundefined - ✗Thinking
?.suppresses errors thrown inside the property access itself
Follow-up questions
- →Why does
count ?? 0behave differently fromcount || 0? - →What does
a?.b.cdo whenais defined butbisundefined?
JuniorTheoryCommonWhich checks make TypeScript narrow a union down to one member?
Which checks make TypeScript narrow a union down to one member?
Narrowing is driven by runtime checks the compiler understands: typeof, instanceof, in, Array.isArray, a truthiness test, and equality against a literal discriminant. Inside the guarded branch the value has the narrowed type; the else branch keeps whichever members remain.
Common mistakes
- ✗Believing narrowing needs an
asassertion rather than a plain runtime check - ✗Expecting
instanceofto narrow to an interface, which has no runtime constructor - ✗Assuming the else branch keeps the full union instead of the remaining members
Follow-up questions
- →Why can
instanceofnot narrow a value down to an interface? - →What does the else branch of
typeof x === 'string'narrow to?
MiddleCodeCommonMake a switch over a union fail to compile when a variant is added
Make a switch over a union fail to compile when a variant is added
In the default branch, assign the switch subject to never. Once every case is handled the subject has narrowed to never, so the assignment type-checks. Add a member and the residue is no longer never: the assignment fails to compile.
Common mistakes
- ✗Using
default: throwalone, which compiles fine when a member is added - ✗Typing the discriminant as
string, so the subject never narrows tonever - ✗Expecting the check to fire at runtime rather than at compile time
Follow-up questions
- →Why is
neverthe only type that makes this assignment work? - →How would you reuse the check across many switches without copying it?
MiddleTheoryCommonWhat does the non-null assertion ! actually do, and what does it cost?
What does the non-null assertion ! actually do, and what does it cost?
x! strips null and undefined from the static type and does nothing else. It emits no runtime check and is erased, so it is a promise rather than a proof: if the value really is nullish the code still throws at the first member access, only now the compiler has been told to stay quiet about it.
Common mistakes
- ✗Believing
!inserts a runtime check instead of only silencing the compiler - ✗Using
!where a real guard or an assertion function would prove the claim - ✗Assuming the compiler validates the assertion via control-flow analysis
Follow-up questions
- →When is
!on a class field's definite assignment justified? - →What would you write instead of
!to keep the check at runtime?
MiddleCodeCommonWrite a type predicate that narrows an unknown value to a User
Write a type predicate that narrows an unknown value to a User
A user-defined type guard is a function whose return type is value is User. When it returns true the compiler narrows the argument to User in that branch. The body must genuinely validate the shape at runtime — the predicate is trusted, never verified.
Common mistakes
- ✗Forgetting
typeof null === 'object', so anullpayload slips through the guard - ✗Believing the compiler checks the predicate body against the declared shape
- ✗Returning
booleaninstead ofvalue is User, which gives no narrowing at all
Follow-up questions
- →What happens to the narrowing if the predicate body checks the wrong field?
- →How would a schema validator replace this hand-written guard?
JuniorTheoryOccasionalHow does the in operator narrow a union of object types?
How does the in operator narrow a union of object types?
'swim' in animal asks at runtime whether that key exists on the value, and the compiler narrows on the answer: the true branch keeps only the members that declare swim, the false branch keeps the rest. It reads a plain object shape, so no class is needed.
Common mistakes
- ✗Thinking
inis a compile-timekeyofquery rather than a runtime key check - ✗Assuming
inneeds a class or a shared discriminant field to narrow - ✗Forgetting the false branch narrows too, to the members without that key
Follow-up questions
- →How does
inbehave when the key is declared optional on every member? - →When would you reach for a discriminant field instead of an
incheck?
MiddleTheoryOccasionalWhat does asserts x is T give you that a type predicate does not?
What does asserts x is T give you that a type predicate does not?
An assertion function narrows by throwing: once assertIsUser(v) returns, v is a User for the rest of the scope, with no if block around it. The call target must carry an explicit type annotation, so an inferred arrow const is rejected, and its body is trusted, never verified.
Common mistakes
- ✗Assigning the assertion to an inferred
const, which the compiler rejects outright - ✗Expecting the narrowing to be confined to an
ifbranch as with a predicate - ✗Assuming the compiler validates that the body really throws on a bad value
Follow-up questions
- →Why does an inferred arrow
constfail as an assertion call target? - →When would you prefer
assertsover a boolean type predicate?
MiddleDesignOccasionalA colleague models the state of a data-fetching store as a single interface with four optional fields — isIdle, isLoading, data, and error — and tells the four states apart by reading the boolean flags. In review you argue for a discriminated union over a status field instead, where each state carries only the data that belongs to it. Explain what each design lets the compiler prove, which impossible combinations the flag-based shape still admits, how consumers read the state in each case, and what the union costs you when a new state is added later.
A colleague models the state of a data-fetching store as a single interface with four optional fields — isIdle, isLoading, data, and error — and tells the four states apart by reading the boolean flags. In review you argue for a discriminated union over a status field instead, where each state carries only the data that belongs to it. Explain what each design lets the compiler prove, which impossible combinations the flag-based shape still admits, how consumers read the state in each case, and what the union costs you when a new state is added later.
Model each state as its own member keyed by a literal status, carrying only the data that state owns: {status:'loading'}, {status:'success'; data}, {status:'error'; error}. Impossible combinations then cannot be constructed, and consumers narrow on status instead of juggling flags.
Common mistakes
- ✗Keeping optional
dataanderrorfields, soloading + errorstays representable - ✗Assuming boolean flags give the compiler anything to narrow on
- ✗Forgetting that adding a member breaks every consumer that switches exhaustively
Follow-up questions
- →How would you keep the previous
datavisible while a refetch is loading? - →Why is a boolean flag a poor discriminant even when there are only two states?
SeniorCodeOccasionalType a reducer so that an unhandled action fails the build
Type a reducer so that an unhandled action fails the build
Switch on the literal type field and end with default: return assertNever(action), where assertNever(value: never): never throws. Every handled case narrows its member away, so a complete union leaves never and a new action fails the build.
Common mistakes
- ✗Typing the
assertNeverparameter asunknownorany, which accepts every action - ✗Returning the old state from
default, which compiles happily forever - ✗Widening the union with a
stringtypefield, so nothing ever narrows tonever
Follow-up questions
- →Why must the
assertNeverparameter be typedneverrather thanunknown? - →How does this interact with an action union assembled from many feature slices?
SeniorTheoryRareDoes a narrowing survive a function call that could reassign the value?
Does a narrowing survive a function call that could reassign the value?
It does, and that is a deliberate unsoundness. The compiler discards a narrowing only on an assignment it can see in the same flow, so a call that reassigns a captured let or mutates obj.prop leaves the stale narrowing standing. What it does discard is a narrowing read inside a callback.
Common mistakes
- ✗Believing an arbitrary call re-widens a narrowed property back to its declared type
- ✗Assuming a narrowing always reaches into the body of a callback
- ✗Trusting a narrowed
obj.propafter handingobjto code that can mutate it
Follow-up questions
- →Why does copying
obj.propinto aconstmake the narrowing hold inside a callback? - →Which assignment must the compiler see before it discards a narrowing?
SeniorDesignRareYou maintain a shared package that exports a discriminated union Event with three members, consumed by a dozen applications. A product request needs a fourth member. One reviewer says a union is an open set, so adding a member is additive and safe; another says it breaks every consumer and demands a major version bump. Explain who is right for code that produces an Event and for code that consumes one, what the exhaustiveness pattern turns the change into, and how you would ship the fourth member without silently breaking downstream applications.
You maintain a shared package that exports a discriminated union Event with three members, consumed by a dozen applications. A product request needs a fourth member. One reviewer says a union is an open set, so adding a member is additive and safe; another says it breaks every consumer and demands a major version bump. Explain who is right for code that produces an Event and for code that consumes one, what the exhaustiveness pattern turns the change into, and how you would ship the fourth member without silently breaking downstream applications.
Both, on different sides. For code that produces an Event the union merely widened, so old producers still type-check. For code that consumes one, every exhaustive switch now has an unhandled member, which the never check turns into a compile error. That is breaking and needs a major bump.
Common mistakes
- ✗Calling the change additive because the union itself only grew
- ✗Believing a
defaultbranch makes an exhaustive switch safe against a new member - ✗Confusing the producer side, where widening is safe, with the consumer side
Follow-up questions
- →How would a separate
EventV2type let consumers migrate on their own schedule? - →Why does a
defaultbranch weaken the guarantee rather than strengthen it?