# Type narrowing

Source: https://codewiki.com/typescript/type-narrowing/

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

| Check | What a true result proves | Boundary |
| --- | --- | --- |
| `typeof value === "string"` | A JavaScript primitive category | `typeof null` is still `"object"` |
| `value instanceof Error` | The named constructor's `prototype` is in the chain | Plain JSON won't pass, and cross-realm values may fail |
| `"id" in value` | A property exists on the object or its prototype chain | It proves neither the value type nor own-property status |
| `value === null` | A relation to one literal or another variable | `value == null` removes both `null` and `undefined` |
| `Array.isArray(value)` | The runtime value is an array | Its elements still need checking |
| `result.status === "ok"` | A union member carrying that literal discriminant | The 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.

<!-- quick -->

```typescript
// file: 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));
}
```

```text
missing
text:READY
number:12.3
date:2026-09-04
```


<!-- /quick -->

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.

```typescript
// file: 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);
}
```

```text
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.

```typescript
// file: 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));
}
```

```text
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.

```typescript
// file: 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");
}
```

```text
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

> **Pitfall:** `if (attempts)` sends `0` down the same false branch as `null`, and `if (name)` drops a valid empty string. The type narrows, but the program may violate its domain rules.

**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

> **Pitfall:** `function isUser(value): value is User` may check only `"id" in value` while callers believe every required field and field type is valid. A predicate is a trusted declaration, not an automatically verified proof.

**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

> **Pitfall:** `JSON.parse(raw) as Order` performs no check. It suppresses compiler errors while letting a bad shape enter the whole call chain under a precise-looking type.

**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

> **Pitfall:** After a property passes a check, assignment to the object or property can change its observed type. Callbacks, aliases, and async gaps also make it harder to know whether the runtime value still meets the old condition.

**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

> **Pitfall:** A discriminated union's `default: return "unknown"` swallows members added later. The code still compiles, but the new state receives no domain-specific handling.

**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

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 obtained | Still 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 value` | Value 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.

<!-- /deep -->

[Checkpoint: typescript/type-narrowing](https://codewiki.com/typescript/type-narrowing/#checkpoint)

## Further reading

- [TypeScript Handbook: Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
- [TypeScript 5.4: Preserved narrowing in closures following last assignments](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-4.html)
- [TypeScript TSConfig: `strictNullChecks`](https://www.typescriptlang.org/tsconfig/strictNullChecks.html)
- [TypeScript 2.0: Control flow based type analysis](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-0.html)
