Type narrowing

How TypeScript narrows unions and unknown through control flow, with exhaustive branches, honest predicates, and stable local bindings.

level intermediate time 11 min at Standard depth
version TypeScript 6
what

Control-flow narrowing lets TypeScript reduce a value’s possible type at each point based on checks, assignments, and reachable paths.

trap

A narrowed type is a compiler conclusion at one program point, not a runtime conversion; assignments, mutable aliases, and dishonest predicates can break its assumptions.

fix

Use guards that match runtime facts, give unions a stable discriminant, and make every exhaustive branch prove its remainder is never.

What it is and why it exists

Type narrowing is TypeScript’s process of refining a broad type into a more specific one at a point in control flow. A parameter may be declared as string | number, but inside the branch where typeof value === "string" holds, the compiler treats value as only string. After that path ends, it calculates the type again from the branches that can still reach the next point.

This mechanism makes operations on a union type safe. If a value might be a string or a number, unchecked code may use only capabilities shared by both members. A guard gives the compiler runtime evidence before the code calls toUpperCase() or toFixed().

Narrowing also applies to unknown, nullable values, optional properties, and state objects. It appears at API boundaries, in event handlers, around parsed results, and in error handling. With strictNullChecks enabled, null and undefined are distinct types, so a null check can form a useful static proof.

Narrowing doesn’t modify data or carry type information into JavaScript. typeof, equality, and property access execute at runtime, while interfaces and unions undergo type erasure . External data therefore needs a real check; an annotation can’t make it trustworthy.

How it works

A variable has both a declared type and an observed type at the current location. The declared type controls which values may be assigned throughout its scope. The observed type describes the values that can still reach the current path. A guard reduces the observed type without permanently rewriting the declaration.

The compiler records flow facts for each branch. True and false paths produce different facts; return, throw, and break remove possibilities from code they prevent from being reached; when paths join, the compiler combines their remaining types. Assignments create new facts too, so one name can have different observed types on adjacent lines.

Guards the compiler understands

A type guard is a runtime condition the compiler can use for narrowing. Each check supplies different evidence, so the forms aren’t interchangeable.

CheckWhat a true result provesBoundary
typeof value === "string"A JavaScript primitive categorytypeof null is still "object"
value instanceof ErrorThe named constructor’s prototype is in the chainPlain JSON won’t pass, and cross-realm values may fail
"id" in valueA property exists on the object or its prototype chainIt proves neither the value type nor own-property status
value === nullA relation to one literal or another variablevalue == null removes both null and undefined
Array.isArray(value)The runtime value is an arrayIts elements still need checking
result.status === "ok"A union member carrying that literal discriminantThe object itself must already be trusted

Truthiness checks narrow too, but they answer JavaScript’s truthy-or-falsy question. if (value) excludes null and undefined, while also keeping 0, NaN, "", 0n, and false out of the true branch. Check for nullish values explicitly when those other values are valid in the domain.

Equality can relate two variables. If left is string | number and right is string | boolean, then both can only be strings when left === right. The conclusion comes from the common possibility in their unions, not from the comparison converting either value.

Reachability, assignment, and joins

Early returns often make the remaining path clearer. If the numeric branch has returned, the same parameter no longer includes number later in the function. This ages better than repeating a type assertion at every use.

After an assignment, the observed type comes from the value assigned, but future assignments are still checked against the declared type. A variable declared as string | number can be observed as string after receiving text and can later receive a number. Assigning a Boolean is always an error because it falls outside the declared type.

When paths join, the compiler combines the outcome of every reachable path. If one branch assigns a string and another assigns a number, the observed type at the join becomes string | number again. Following that flow is more dependable than memorizing one editor tooltip.

Discriminated unions and exhaustiveness

A discriminated union gives every member a shared literal field such as status. Checking it narrows the whole object together with its payload, rather than guessing state from several optional properties. The discriminant should be stable, not ordinary mutable data doing two jobs.

After all members have been removed, the remaining type is never. Passing a default branch’s value to a function that accepts never turns a newly added but unhandled member into a compile error. A default branch that merely returns generic text can’t provide that warning.

Custom predicates and assertion functions

When a complex shape needs a reusable check, a function can return a type predicate such as value is Command. The true branch keeps Command, and the false branch excludes it. The compiler checks that the predicate type is compatible with the parameter, but it doesn’t prove that the body checks every field promised by the signature.

An assertion function uses asserts value is Type or asserts condition. Code after a normal return receives the narrow type, while failure must throw or never return. It fits unrecoverable invariants; ordinary invalid input is usually clearer as a discriminated result.

Examples

The four programs below cover built-in guards, control flow after assignment, discriminated unions, and an unknown boundary. Each one was checked in strict mode with TypeScript 6 and actually executed with local tsx.

Removing members with early returns

The first function handles null, strings, and Date instances in sequence. All three paths return, so only number remains at the final line.

builtin-narrowing.ts
type Input = string | number | Date | null;

function describe(value: Input): string {
  if (value === null) {
    return "missing";
  }

  if (typeof value === "string") {
    return `text:${value.trim().toUpperCase()}`;
  }

  if (value instanceof Date) {
    return `date:${value.toISOString().slice(0, 10)}`;
  }

  return `number:${value.toFixed(1)}`;
}

const inputs: Input[] = [null, "  ready ", 12.25, new Date("2026-09-04T00:00:00Z")];

for (const input of inputs) {
  console.log(describe(input));
}
missing
text:READY
number:12.3
date:2026-09-04

The value === null test must come before broad object handling because typeof null === "object". The instanceof Date check works for instances created by code. A date in JSON remains a string and won’t become a Date because of an annotation.

The numeric branch needs no as number. Its safety follows from every reachable path above it, not from an instruction to the compiler. Adding another member to Input forces the implementation to reconsider this line.

Keeping a narrow type after assignment

The string form of the base address is converted and assigned back to base. After the if, both paths hold a URL, and the arrow function is created after the last assignment, so it can keep using that narrow type.

assignment-flow.ts
function makeJobUrls(base: string | URL, jobIds: number[]): string[] {
  if (typeof base === "string") {
    base = new URL(base);
  }

  // The arrow function is created after the last assignment to base.
  return jobIds.map((jobId) => `${base.origin}/jobs/${jobId}`);
}

const urls = makeJobUrls("https://queue.example/api?legacy=1", [7, 11]);

for (const url of urls) {
  console.log(url);
}
https://queue.example/jobs/7
https://queue.example/jobs/11

The parameter’s declared type remains string | URL, which is why the earlier branch may assign a URL to it. Control flow proves the observed type when the arrow function is created. If any nested function also assigned to base, the compiler couldn’t keep relying on that proof.

Production code often saves the stable result as const normalizedBase = base. That helps the compiler and tells readers exactly which non-reassigned value later callbacks depend on.

Driving state handling with a discriminant

Every job state has the same status field but a different payload. Each switch branch can access only the matching member’s fields, while the default branch requires that no member remains.

job-state.ts
type JobState =
  | { status: "queued"; position: number }
  | { status: "running"; worker: string }
  | { status: "failed"; reason: string }
  | { status: "done"; artifact: string };

function assertNever(value: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}

function summarize(state: JobState): string {
  switch (state.status) {
    case "queued":
      return `queued:${state.position}`;
    case "running":
      return `running:${state.worker}`;
    case "failed":
      return `failed:${state.reason}`;
    case "done":
      return `done:${state.artifact}`;
    default:
      return assertNever(state);
  }
}

const states: JobState[] = [
  { status: "queued", position: 2 },
  { status: "running", worker: "runner-3" },
  { status: "done", artifact: "build-91.zip" },
];

for (const state of states) {
  console.log(summarize(state));
}
queued:2
running:runner-3
done:build-91.zip

If you add { status: "paused"; until: string } without another case, assertNever(state) receives the paused member and produces a type error. This checks the static union only. An unvalidated external object can still arrive at runtime with any status.

Giving each member its own required fields is safer than piling up optional properties. { status: string; position?: number; worker?: string } doesn’t express which payload belongs with which status, and narrowing can’t recover that relationship.

Narrowing unknown to a domain union

The final example puts the JSON result into unknown immediately. Its predicate first proves a non-null, non-array object, then checks the discriminant and the payload required by each branch.

command-boundary.ts
type Command =
  | { kind: "retry"; attempts: number }
  | { kind: "cancel"; reason: string };

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isCommand(value: unknown): value is Command {
  if (!isRecord(value)) return false;

  return (
    (value.kind === "retry" &&
      typeof value.attempts === "number" &&
      Number.isInteger(value.attempts) &&
      value.attempts >= 0) ||
    (value.kind === "cancel" && typeof value.reason === "string")
  );
}

function decodeCommand(raw: string): Command | undefined {
  try {
    const value: unknown = JSON.parse(raw);
    return isCommand(value) ? value : undefined;
  } catch {
    return undefined;
  }
}

for (const raw of [
  '{"kind":"retry","attempts":0}',
  '{"kind":"cancel","reason":"duplicate"}',
  '{"kind":"retry","attempts":"3"}',
  "not json",
]) {
  const command = decodeCommand(raw);
  console.log(command ? command.kind : "rejected");
}
retry
cancel
rejected
rejected

"kind" in value isn’t enough: it checks neither the permitted literal values nor the related payload. The body and signature of isCommand() must change together. Adding a union member means updating the predicate, handler, and tests as one change.

Catching malformed JSON.parse() input is part of the boundary contract. Successful parsing proves only that the text is valid JSON, not that the resulting object is a Command. Test both failure classes even if the interface maps both to undefined.

Pitfalls

Using truthiness as a null check

Fix: use value !== null, value !== undefined, or value != null when only nullish values should be removed. Add boundary tests for 0, an empty string, and false to say whether each one should survive.

Promising more than a guard checks

Fix: start from unknown and check the container, discriminant, required fields, and domain constraints. If a helper answers only a smaller question, return boolean or declare the smaller type its evidence supports.

Treating an assertion as narrowing

Fix: assign external data to unknown, then produce the domain type through a guard or validator. Reserve as for narrow points where an independent runtime guarantee exists but the checker can’t express it.

Reusing old evidence after mutation

Fix: copy the narrow value you need into a local const before passing it to a callback. Audit every write to the variable, property, and aliases between the guard and use instead of trusting an editor tooltip from one line.

Hiding new members in a broad default

Fix: pass the remainder to assertNever() when the branch must be exhaustive. If the protocol truly allows unknown external states, model one explicitly at the decoding boundary instead of silently widening the internal union.

Deep Flow facts, assignments, and joins

Flow facts, assignments, and joins

A TypeScript narrowing result belongs to a program location, not permanently to a variable. The checker propagates facts through a control-flow graph, and each branch, assignment, or termination can update them. When a diagnostic is surprising, first ask which paths reach that line and what each path last assigned.

The declared type is the assignment ceiling; the observed type is the precision available now. A string | number variable may receive text, use a string method, then receive a number and use a numeric method. Its observed type changes between those operations, while both assignments satisfy one declaration.

A path join discards facts that hold on only one path. If both branches turn a variable into a URL, it remains a URL at the join. If one path keeps a string and the other produces a URL, the join is string | URL. Removing a branch with an early return is often clearer than checking again after the join.

Keep discriminants correlated with their payloads. If code destructures const { status } = state and then mutates the original object independently, a reader must determine whether they still describe one state. For changing state, constructing a fresh union member is easier to reason about than mutating a discriminant in place.

Aliased conditions and properties

A stable const condition can carry narrowing information. For example, the compiler can relate an unmodified constant holding typeof input === "string" back to the original expression. That relationship is no longer reliable if either the condition variable or the checked object is written later.

Object properties are more exposed to aliases than local primitive values. Even where the checker accepts a property access, another reference may change the same object at runtime. TypeScript trades some soundness for practical JavaScript patterns, so a clean type check doesn’t prove the object stayed unchanged across concurrency, callbacks, or library calls.

When a value must cross such a boundary, read it once after the guard and save it in a const. That local marks the snapshot time and shortens the stretch of control flow a reviewer must inspect. If the object itself must preserve an invariant, use immutable updates, encapsulation, or runtime synchronization rather than stronger annotations alone.

Narrowing in closures

TypeScript 6 can preserve a parameter or let variable’s narrow type in certain closures. The non-hoisted function must be created after a definite last assignment, and no nested function may assign to that variable. assignment-flow.ts has exactly that shape.

Once a nested function assigns the variable, even to itself, the checker can’t know what another closure will observe when it runs. Invocation timing generally can’t be proved from local control flow, so the old narrowing is abandoned. This is about time and mutability, not arrow-function versus ordinary-function syntax.

A plain pattern works well here: validate and normalize before creating the closure, then capture the result in a well-named const. For example, normalize string | URL to URL first and let callbacks read only normalizedUrl. The code now records its own boundary instead of asking readers to reconstruct last-assignment analysis.

An await doesn’t automatically discard every narrowing of a local, but it lets other code run before execution resumes. A non-reassigned local primitive is usually straightforward. For a shared mutable object, the static type can’t stop another task from changing a property through an alias, so async review must consider real ownership as well as the compiler’s conclusion.

Predicate contracts and tests

A type predicate describes both outcomes. If isSmallNumber(value): value is number returns true only for small numbers, a larger number reaches a false branch where the caller may already have excluded number. Return plain boolean when the actual condition is narrower than the predicate type, or define a genuine type for that subset.

An assertion function makes a stronger promise: normal return means the condition holds. If its implementation logs a failure and returns, following code receives a narrow type with no runtime guarantee. A negative test must confirm that the failure path throws or otherwise terminates.

Predicate tests should cover every valid union member, each missing field, wrong primitive types, and domain boundaries. They also need values that belong to the target type but might accidentally return false, because callers rely on the false branch too. For decoders, state the policy for malformed JSON, arrays, null, and extra fields.

Static tests confirm that intended branches compile and omitted members fail. Runtime tests confirm that guards agree with actual values. The two answer different questions and can’t replace one another, especially at an external boundary where a successful tsc run validates no incoming data.

The limit of the evidence

Narrowing proves only what the check supports; it doesn’t add domain constraints. typeof amount === "number" still permits NaN and infinities, while typeof id === "string" permits an empty string. If later code relies on a finite number, nonempty text, or a particular format, the guard must check those conditions too.

Evidence obtainedStill unproved
typeof value === "number"Finiteness, integer status, range, and unit
typeof value === "string"Nonemptiness, format, length, and normalization
Array.isArray(value)Element types, length, and relationships between elements
"token" in valueValue type, own-property status, and whether the secret is valid

Structural typing allows extra properties, so an object having every required field doesn’t mean it has only those fields. If a protocol rejects unknown keys, its decoder must compare the key set explicitly. If the protocol is forward compatible, accept extra fields but pass only validated data into business logic.

Narrowing also doesn’t prove that two separate reads happen at one instant. Accessors, proxies, and shared mutable objects can return different results on consecutive property reads. At such a boundary, read once into a local, then validate and use that same value.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?