Type Narrowing and Type Guards
TL;DR
Narrowing is how TypeScript tracks a value’s type through a function — typeof x === 'string' inside an if actually changes x’s type inside the block. Discriminated unions are the idiomatic shape for narrow-able data. When TS can’t narrow on its own, you reach for user-defined type guards (x is T) and assertion functions (asserts x is T).
Interview Q&A
Q: What’s type narrowing?
When the compiler refines a value’s type based on a check, it’s narrowing. TS supports several kinds:
function f(x: string | number) {
if (typeof x === 'string') {
x.toUpperCase() // x: string
} else {
x.toFixed(2) // x: number
}
}
TS does control flow analysis — every branch, return, throw, and &&/||/?? shortcut can change the inferred type going forward.
Q: What narrowing operators does TS recognise?
| Operator | Narrows by |
|---|---|
typeof x === 'string' etc. |
primitive type tag |
x instanceof Foo |
constructor / prototype |
'key' in obj |
property presence |
x === literal |
equality narrowing (literals + unions) |
Array.isArray(x) |
built-in type predicate |
if (x) |
truthiness — strips `null |
&& ` |
|
| switch on a discriminant | discriminated-union narrowing |
Q: What’s a discriminated union?
A union of object types where each member has a common literal property that uniquely identifies it. TS narrows on that property.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rect'; width: number; height: number }
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2
case 'square': return s.side ** 2
case 'rect': return s.width * s.height
}
}
Inside each case, s is narrowed to the right member. Adding a new variant without updating the switch will fail the return-type check (if the function returns number).
Q: How do you make a switch exhaustive?
Use the never trick — the default case assigns the value to never, which fails to compile if any member of the union isn’t handled.
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`)
}
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2
case 'square': return s.side ** 2
case 'rect': return s.width * s.height
default: return assertNever(s) // ← compile error if Shape grows
}
}
This is the senior pattern for “fail at compile time when someone adds a new variant.”
Q: What’s a user-defined type guard?
A function whose return type is param is SomeType. When it returns true, TS narrows the argument to that type in the caller.
interface Cat { kind: 'cat'; meow: () => void }
interface Dog { kind: 'dog'; bark: () => void }
function isCat(animal: Cat | Dog): animal is Cat {
return animal.kind === 'cat'
}
function pet(a: Cat | Dog) {
if (isCat(a)) a.meow()
else a.bark()
}
Type guards are the escape hatch when TS can’t narrow on its own (e.g. across function boundaries or with runtime libraries).
Q: What are assertion functions (asserts)?
A function that throws if the assertion fails, and narrows the type in the caller from that point onward.
function assertIsString(x: unknown): asserts x is string {
if (typeof x !== 'string') throw new Error('not a string')
}
function f(x: unknown) {
assertIsString(x)
x.toUpperCase() // x: string from here on
}
Useful for runtime validation that the rest of the function then trusts. Combine with zod/io-ts return shapes.
Q: What’s the difference between is T and asserts x is T?
is Treturns a boolean; narrowing only happens inside theif.asserts x is Treturnsvoidbut throws on failure; narrowing applies for the rest of the function.
Choose is when the caller might handle false; choose asserts when failure means “stop.”
Q: How do you narrow with in?
The in operator checks for property presence, and TS uses it to narrow union members:
type Resp = { data: User } | { error: string }
function handle(r: Resp) {
if ('error' in r) console.error(r.error)
else console.log(r.data)
}
Useful when you don’t have a clean discriminant property.
Q: What’s “narrowing loss”?
Narrowing tracks a binding, not a value. Move the value through a function call, await, or closure boundary and TS forgets the narrowing because the underlying value could have changed.
function f(x: string | null) {
if (x === null) return
// x: string here
setTimeout(() => {
x.toUpperCase() // error — x: string | null again inside the closure
}, 0)
}
Workarounds: alias the narrowed value to a const, or re-check inside the closure.
function f(x: string | null) {
if (x === null) return
const safe = x // const lock-in
setTimeout(() => safe.toUpperCase(), 0)
}
Q: How does narrowing interact with unknown?
unknown requires narrowing before any use — that’s its whole purpose. any allows everything; unknown allows nothing until you prove the type.
function parse(json: string): unknown {
return JSON.parse(json)
}
const data = parse('{}')
// data.foo // error — unknown
if (typeof data === 'object' && data !== null && 'foo' in data) {
// data: object with foo
}
The right discipline at any boundary (network, JSON, third-party) is unknown + narrow, not any.
Q: Custom guard for an array of T?
function isArrayOf<T>(arr: unknown, guard: (x: unknown) => x is T): arr is T[] {
return Array.isArray(arr) && arr.every(guard)
}
const data: unknown = ['a', 'b']
if (isArrayOf(data, (x): x is string => typeof x === 'string')) {
data.map(s => s.toUpperCase())
}
Generic + composed guards = type-safe data validation.
Gotchas / edge cases
- Narrowing through array methods is limited —
.filter(Boolean)doesn’t always stripnull/undefined. Use a typed guard:.filter((x): x is T => x != null). typeof x === 'object'doesn’t narrow outnull—typeof null === 'object'. Add&& x !== null.- Switch fall-through breaks discriminant narrowing — explicit
caseper variant. - Narrowing is lost across
await— once a function suspends, TS can’t prove the value didn’t change. asserts x is Trequires an explicit return type in the signature — TS won’t infer it.- User-defined type guards are trusted blindly by TS — if your guard lies, the type system lies. Write tests.
x is neveris a sometimes-useful “this is unreachable” marker, often paired withassertNever.
What a senior is expected to say
A junior writes if (typeof x === 'string'). A senior reaches for discriminated unions as the first data-modeling tool, uses an assertNever default case to make adding a new variant a compile error, treats anything crossing a boundary (HTTP, JSON, third-party SDKs) as unknown rather than any, and writes assertion functions to bridge runtime validation (zod) back to compile-time types. The signal is: do you design data to narrow, or do you cast it after the fact?
Cross-references
- Conditional types and
infer(build your own predicates): 02_conditional_and_mapped_types.md anyvsunknownvsneverin depth: 10_typescript_pitfalls.md- Strictness flags that interact with narrowing: 09_strictness_flags.md