frontend / typescript / 05_narrowing_and_guards.md

Type Narrowing and Type Guards

6 min read source

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 T returns a boolean; narrowing only happens inside the if.
  • asserts x is T returns void but 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 strip null/undefined. Use a typed guard: .filter((x): x is T => x != null).
  • typeof x === 'object' doesn’t narrow out nulltypeof null === 'object'. Add && x !== null.
  • Switch fall-through breaks discriminant narrowing — explicit case per variant.
  • Narrowing is lost across await — once a function suspends, TS can’t prove the value didn’t change.
  • asserts x is T requires 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 never is a sometimes-useful “this is unreachable” marker, often paired with assertNever.

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

Further reading