TypeScript6 min read

Type Narrowing Is Evidence That Expires

How control flow, property checks, callbacks, and user-defined predicates affect TypeScript's view of a value.

  • type narrowing
  • type guards
  • control flow

TypeScript narrows a variable when control flow provides evidence that its current value belongs to a smaller type. A typeof check, discriminant comparison, equality test, or early return can all contribute evidence.

function format(value: string | number) {
  if (typeof value === 'string') {
    return value.trim();
  }

  return value.toFixed(2);
}

The compiler does not mutate the declared type of value. It computes a narrower observed type for each reachable point and widens again when the evidence no longer applies.

Property checks say less than they appear to

The in operator narrows union members based on whether they can have a property.

type FileJob = { path: string; encoding?: string };
type MemoryJob = { bytes: Uint8Array };

function size(job: FileJob | MemoryJob) {
  if ('bytes' in job) return job.bytes.byteLength;
  return job.path.length;
}

At runtime, key in object checks only the object’s own properties. It differs from property access mainly because it does not read the value or invoke an inherited getter. This makes it a reliable own-property test for parsed data.

Optional properties can appear on both sides of an in narrowing. If more than one union member permits the property, TypeScript retains each compatible member rather than choosing one by name alone.

Assignments can invalidate evidence

Narrowing follows values and locations. Assigning to a narrowed variable can change its observed type:

let value: string | number = 'ready';

if (typeof value === 'string') {
  value = 12;
  value.toFixed();
}

Property paths are more delicate because another call may mutate the object. TypeScript is conservative in some callback and closure scenarios, especially when a narrowed property is captured for later execution. Copying the property into a local constant can preserve both runtime intent and useful static evidence.

if (options.location) {
  const location = options.location;
  queueMicrotask(() => useLocation(location));
}

The local constant cannot be reassigned through options, so its narrowing remains straightforward.

Predicates package domain knowledge

A function returning value is T is a user-defined type predicate.

type User = { id: string; active: boolean };

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;
  return 'id' in value && 'active' in value;
}

The compiler verifies that the predicate implementation really proves every property of User. If the body merely returns true, or checks only id, TypeScript reports an error because the evidence is insufficient. A predicate is therefore safer than a type assertion for validating external data.

Assertion functions use asserts value is T and narrow after a successful return. They are useful at boundaries where failure should throw instead of returning a boolean.

Discriminants scale better than incidental properties

For application-owned unions, an explicit literal field gives the compiler and readers stable evidence:

type Result =
  | { kind: 'ok'; value: string }
  | { kind: 'error'; error: Error };

A switch on kind also supports exhaustiveness checks by assigning the remaining value to never. This turns a newly added variant into a compile-time prompt at each important consumer.

Narrowing works best when treated as scoped evidence, not a permanent cast. Ask what runtime fact was established, which location it applies to, and whether later code can invalidate it.