Frontend / Typescript / 05_narrowing_and_guards.md

Type Narrowing and Type Guards

Updated 6 min read source
On this page15
  1. What’s type narrowing?
  2. What narrowing operators does TS recognise?
  3. What’s a discriminated union?
  4. How do you make a switch exhaustive?
  5. What’s a user-defined type guard?
  6. What are assertion functions (asserts)?
  7. What’s the difference between is T and asserts x is T?
  8. How do you narrow with in?
  9. What’s “narrowing loss”?
  10. How does narrowing interact with unknown?
  11. Custom guard for an array of T?
  12. Gotchas
  13. Related
  14. Further reading
  15. Interview angle

Type Narrowing and Type Guards

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).

What’s type narrowing?

When the compiler refines a value’s type based on a check, it’s narrowing. TS supports several kinds:

ts
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.

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 | undefined | 0 | ''
&& || ?? short-circuit narrowing
switch on a discriminant discriminated-union narrowing

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.

ts
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).

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.

ts
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
    // ← compile error if Shape grows
    default:       return assertNever(s)
  }
}

This is the senior pattern for “fail at compile time when someone adds a new variant.”

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.

ts
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).

What are assertion functions (asserts)?

A function that throws if the assertion fails, and narrows the type in the caller from that point onward.

ts
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.

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.”

How do you narrow with in?

The in operator checks for property presence, and TS uses it to narrow union members:

ts
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.

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.

ts
function f(x: string | null) {
  if (x === null) return
  // x: string here
  setTimeout(() => {
    // error — x: string | null again inside the closure
    x.toUpperCase()
  }, 0)
}

Workarounds: alias the narrowed value to a const, or re-check inside the closure.

ts
function f(x: string | null) {
  if (x === null) return
  const safe = x   // const lock-in
  setTimeout(() => safe.toUpperCase(), 0)
}

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.

ts
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.

Custom guard for an array of T?

ts
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

  • 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 null — typeof 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.

Further reading

Interview angle

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?