---
title: 'Type Narrowing'
source: 'https://academia.sh/en/courses/typescript/type-narrowing'
course: TypeScript
language: en
updated: '2026-08-17T18:09:53+00:00'
license: 'CC BY-SA 4.0'
---

# Type Narrowing

Type narrowing through control flow analysis; the typeof, in, and instanceof guards, the trap of truthiness narrowing, type predicates, assertion functions, and situations where narrowing is lost.

In earlier lessons, a union type was tested several times and the compiler reduced it
to a single member. The rules of this reduction have not yet been laid out.

**Narrowing** is the compiler shrinking a value's type by looking at the tests in the
control flow. The mechanism behind it is called **control flow analysis**: the
compiler tracks, at every point in the program, which conditions have been proven.
This lesson covers which tests count as proof, how your own validation is communicated
to the compiler, and where the proof gets lost.

## Built-in Guards

A **type guard** is a test that narrows a value's type. The most common one is the
`typeof` operator:

```typescript
function format(value: string | number | null): string {
  if (value === null) {
    return "-";
  }
  if (typeof value === "number") {
    return value.toFixed(1);
  }
  return value.trim();
}

console.log(format(21.4), format("  s-01  "), format(null));
```

The output is `21.4 s-01 -`.

Three branches are three narrowing steps. The `value === null` test narrows by
equality; after the first `return`, the remaining type is `string | number`. After the
`typeof value === "number"` test, the type in the body is `number` and `toFixed` can be
called. By the time the last line is reached, the only remaining possibility is
`string`; the `trim` call needs no further test.

The last step deserves attention: the compiler tracks **early returns**. A branch that
ends with a `return` statement eliminates that possibility from the code that follows.
The guard clause pattern introduced in the Programming Fundamentals course pays off at
the type level too.

Object types have two more guards. The `in` operator checks for field presence:

```typescript
type NumericMeasurement = { id: string; value: number };
type TextMeasurement = { id: string; text: string };

function write(record: NumericMeasurement | TextMeasurement): string {
  if ("value" in record) {
    return record.value.toFixed(1);
  }
  return record.text.trim();
}

console.log(write({ id: "s-01", value: 21.4 }));
console.log(write({ id: "s-02", text: "  broken  " }));
```

Output:

```text
21.4
broken
```

The `instanceof` operator distinguishes class instances:

```typescript
class FileSource {
  constructor(readonly path: string) {}
}

class NetworkSource {
  constructor(readonly address: string) {}
}

function describe(source: FileSource | NetworkSource): string {
  if (source instanceof FileSource) {
    return `file: ${source.path}`;
  }
  return `network: ${source.address}`;
}

console.log(describe(new FileSource("/data/measurement.jsonl")));
console.log(describe(new NetworkSource("10.0.0.7:9000")));
```

Output:

```text
file: /data/measurement.jsonl
network: 10.0.0.7:9000
```

What these three have in common is that all of them are tests that **actually run at
run time**. Narrowing is not an assumption of the type system; it is a test in the code
reflected at the type level.

## The Trap of Truthiness Narrowing

Checking a value's truthiness also narrows, but it eliminates more than expected:

```typescript
function label(value: number | undefined): string {
  if (!value) {
    return "no measurement";
  }
  return value.toFixed(1);
}

console.log(label(21.4));
console.log(label(0));
console.log(label(undefined));
```

Output:

```text
21.4
no measurement
no measurement
```

This file passes the type check, but the second output is wrong: `0` is a valid
measurement. The list of falsy values established in the JavaScript Fundamentals
course comes into play here — `0`, `""`, and `NaN` also count as falsy, and the
`!value` test eliminates them too.

The type system does not catch this mistake, because the narrowing was done correctly:
after the `!value` block, the type really is `number`. The bug is not in the type, it
is in the choice of test.

Rule: **write an equality test for `undefined` and `null`.** `if (value === undefined)`
or `if (value == null)` — the latter eliminates both the `null` and the `undefined`
case, and this is the one defensible use of loose equality.

## Type Predicates

When validation is moved into a separate function, narrowing is lost: the compiler
does not know what a function returning `boolean` has proven. A **type predicate**
carries this information in the signature:

```typescript
interface Measurement {
  id: string;
  value: number;
  unit: "C" | "Pa" | "%";
}

function isMeasurement(value: unknown): value is Measurement {
  if (typeof value !== "object" || value === null) {
    return false;
  }
  const candidate = value as Record<string, unknown>;
  return (
    typeof candidate.id === "string" &&
    typeof candidate.value === "number" &&
    (candidate.unit === "C" || candidate.unit === "Pa" || candidate.unit === "%")
  );
}

function parse(raw: string): string {
  const parsed: unknown = JSON.parse(raw);
  if (!isMeasurement(parsed)) {
    return "invalid record";
  }
  return `${parsed.id}=${parsed.value}${parsed.unit}`;
}

console.log(parse('{"id":"s-01","value":21.4,"unit":"C"}'));
console.log(parse('{"id":"s-02","value":"22.1","unit":"C"}'));
console.log(parse('{"id":"s-03","value":22.1,"unit":"K"}'));
```

Output:

```text
s-01=21.4C
invalid record
invalid record
```

The return type is written not as `boolean` but as `value is Measurement`. This tells
the compiler: "when this function returns `true`, accept that its argument is
`Measurement`." The result is that, inside `parse`, `parsed` stops being `unknown` and
becomes `Measurement`.

This is where the course's central point comes together: validation is done at run
time, and a type predicate lets that be recorded **as proof**. The model can now be
safely built from external data.

The predicate has a cost. The compiler does not check that the body actually proves
what it claims:

```typescript
interface Measurement {
  id: string;
  value: number;
}

function isMeasurement(value: unknown): value is Measurement {
  return typeof value === "object";
}

const candidate: unknown = null;
if (isMeasurement(candidate)) {
  console.log(candidate.value.toFixed(1));
}
```

The file passes the type check; when run:

```text
TypeError: Cannot read properties of null (reading 'value')
```

The body gives `typeof null === "object"` as `true` and produces a false proof. A type
predicate is a **debt**, much like `as`: whoever writes it is responsible for its
correctness. This is why predicate bodies are kept short and tested.

## Assertion Functions

The predicate's second form throws on a value that fails the test:

```typescript
interface Measurement {
  id: string;
  value: number;
}

function assertIsMeasurement(value: unknown): asserts value is Measurement {
  if (
    typeof value !== "object" ||
    value === null ||
    typeof (value as Record<string, unknown>).id !== "string" ||
    typeof (value as Record<string, unknown>).value !== "number"
  ) {
    throw new TypeError("not a measurement record");
  }
}

const parsed: unknown = JSON.parse('{"id":"s-01","value":21.4}');
assertIsMeasurement(parsed);
console.log(parsed.id, parsed.value.toFixed(1));
```

The output is `s-01 21.4`.

The `asserts value is Measurement` declaration narrows the type in the case where the
call **returns normally**. No `if` block is needed; in all the code after the call,
`parsed` is a `Measurement`.

The choice between the two forms depends on whether invalid data is an expected
situation. The same distinction was made in the Type Conversion lesson of the
Programming Fundamentals course: invalidity in user input is ordinary and is handled
with a predicate; invalidity in data the program produces itself is exceptional and an
assertion function is appropriate.

There is one constraint: the name an assertion function is called through must be an
explicitly typed declaration. If the function is first assigned to an untyped constant
and called through that constant, the compiler gives the `TS2775` diagnostic: every
name at the target of an assertion call must carry an explicit type annotation.

## Where Narrowing Is Lost

Control flow analysis works as long as it can assume the value has not changed since
the test. That assumption does not hold inside a closure:

```typescript
interface Measurement {
  id: string;
  last?: number;
}

function write(record: Measurement): void {
  if (record.last !== undefined) {
    console.log(record.last.toFixed(1));
    [1, 2].forEach(() => {
      console.log(record.last.toFixed(1));
    });
  }
}

write({ id: "s-01", last: 21.4 });
```

```text
n6.ts(10,19): error TS18048: 'record.last' is possibly 'undefined'.
```

The eighth line is valid, the tenth is not. The difference is that it is not known
**when** the callback will run: `forEach` runs synchronously, but the compiler cannot
know that, and the `record.last` field could have been deleted in between. The same
problem is even more visible in timers and asynchronous code.

The fix is to capture the narrowed value in a local constant:

```typescript
interface Measurement {
  id: string;
  last?: number;
}

function write(record: Measurement): void {
  const last = record.last;
  if (last !== undefined) {
    console.log(last.toFixed(1));
    [1, 2].forEach(() => {
      console.log(last.toFixed(1));
    });
  }
}

write({ id: "s-01", last: 21.4 });
```

A name bound with `const` cannot change; the compiler can carry the narrowing into the
closure. The output is three lines of `21.4`.

## Summary

- Narrowing is control-flow tests reflected at the type level; `typeof`, `in`,
  `instanceof`, and equality tests are built-in guards.
- A truthiness test also eliminates valid values like `0` and `""`; an equality test
  should be written for `null` and `undefined`.
- A type predicate (`value is T`) communicates the result of validation moved into a
  separate function to the compiler; the body's correctness is not checked — the
  predicate is a debt.
- An assertion function (`asserts value is T`) throws on an invalid value and narrows
  the type when it returns normally.
- Narrowing does not carry into a closure; capturing the narrowed value in a local
  constant crosses that boundary.

## Next Step

The various shapes of a measurement record have so far been written out by hand: a
read-only version, an update version with optional fields, a summary version carrying
only a few fields. Each is a transform of the original type, and they need to be
updated together whenever the type changes. The next lesson builds mapped types, which
compute these transforms at the type level.
