---
title: "TypeScript: Type Narrowing"
slug: "typescript-narrowing"
category: "coding"
tags: ["coding", "typescript", "narrowing", "guards", "unions"]
status: "stable"
last_updated: 2026-05-14
summary: "Narrow unions with typeof, instanceof, in, discriminants, user-defined predicates, never exhaustiveness, and assert functions."
related: ["[[coding/typescript]]", "[[coding/typescript-strict-mode]]", "[[coding/typescript-generics]]", "[[coding/typescript-runtime-validation]]", "[[coding/typescript-utility-types]]"]
---

## Overview

Narrowing is how a wide type becomes a usable type. The compiler narrows on type guards, discriminants, and assertion functions; everything else is a cast. Read [[coding/typescript-strict-mode]] first; narrowing only matters because strict mode makes wide types unsafe to use.

## Narrow primitives with `typeof`

`typeof` is the cheapest guard. Use it for the seven `typeof` results: `"string"`, `"number"`, `"bigint"`, `"boolean"`, `"symbol"`, `"undefined"`, `"object"`, `"function"`.

```ts
function format(value: string | number | null): string {
  if (typeof value === "string") return value
  if (typeof value === "number") return value.toFixed(2)
  return "n/a"
}
```

Note that `typeof null` is `"object"`. To rule out `null`, compare to `null` directly or check `value != null`.

## Narrow class instances with `instanceof`

`instanceof` narrows to the constructor's instance type. Use it after a `catch` (with `useUnknownInCatchVariables` on, the variable is `unknown`).

```ts
try {
  await loadInvoice(id)
} catch (err) {
  if (err instanceof HttpError) {
    return { status: err.status }
  }
  if (err instanceof Error) {
    logger.error({ message: err.message })
    return { status: 500 }
  }
  throw err
}
```

`instanceof` does not work across realm boundaries (iframes, vm contexts) and does not work on plain object literals. Use a discriminant for those cases.

## Narrow object shapes with `in`

`in` narrows when one branch has a property the other does not. It is the right tool for two object shapes that lack a discriminant.

```ts
type Cat = { meow(): void }
type Dog = { bark(): void }

function speak(animal: Cat | Dog) {
  if ("meow" in animal) {
    animal.meow()
  } else {
    animal.bark()
  }
}
```

For three or more shapes, add an explicit discriminant field instead; `in` checks compound poorly.

## Prefer discriminated unions over `in` and `instanceof`

A discriminated union has a literal-typed `kind` (or `type`, `status`) field. The compiler narrows on equality with the discriminant.

```ts
type Invoice =
  | { status: "draft"; id: string }
  | { status: "sent"; id: string; sentAt: Date }
  | { status: "paid"; id: string; sentAt: Date; paidAt: Date }

function display(invoice: Invoice): string {
  switch (invoice.status) {
    case "draft": return `Draft ${invoice.id}`
    case "sent": return `Sent at ${invoice.sentAt.toISOString()}`
    case "paid": return `Paid at ${invoice.paidAt.toISOString()}`
  }
}
```

The compiler narrows `invoice` inside each `case` to exactly the variant with that status. This is the most useful narrowing pattern in TypeScript. See [[coding/typescript]] for why narrow types beat flat-with-optionals.

## Exhaustiveness-check switches with `never`

Add a `default` branch that assigns to `never`. The compiler errors if a new union member is added and the switch is not updated.

```ts
function display(invoice: Invoice): string {
  switch (invoice.status) {
    case "draft": return `Draft`
    case "sent": return `Sent`
    case "paid": return `Paid`
    default: {
      const _exhaustive: never = invoice
      throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`)
    }
  }
}
```

When a fourth status arrives, the assignment to `never` fails to compile and points you at every switch that needs an update. This converts a class of runtime bugs into compile errors.

## Write user-defined type predicates sparingly

A function that returns `value is T` is a custom narrowing guard. Use it when the built-in guards do not express the check.

```ts
function isInvoice(value: unknown): value is Invoice {
  return (
    typeof value === "object" &&
    value !== null &&
    "status" in value &&
    typeof (value as { status: unknown }).status === "string"
  )
}

if (isInvoice(data)) {
  // data is Invoice here
}
```

The compiler trusts the predicate; if the body lies, you get a runtime bug. For untrusted data (HTTP, FS, env), use a schema parser instead of a predicate. See [[coding/typescript-runtime-validation]].

## Use assertion functions for invariants

An assertion function throws on failure and narrows on success. Signature: `function assertX(value): asserts value is X`.

```ts
function assertDefined<T>(value: T | undefined, message: string): asserts value is T {
  if (value === undefined) throw new Error(message)
}

const row = rows[0]
assertDefined(row, "expected at least one row")
// row is T here, not T | undefined
```

Use these at boundaries between "I know this is set" and the type system disagreeing. Pair with `noUncheckedIndexedAccess` to keep the `T | undefined` honest. See [[coding/typescript-strict-mode]] for the flag rationale.

## Narrow with equality, then act

The narrowing flow is: check, then use inside the narrowed scope. Do not re-check; the compiler already knows.

```ts
function trim(value: string | null): string {
  if (value === null) return ""
  return value.trim()  // value is string here
}
```

Re-checking (`if (value !== null) return value.trim()`) makes the reader prove the narrowing twice. Pick the early return; let the rest of the function read the narrowed type. See [[coding/typescript-utility-types]] for `NonNullable<T>` and the related helpers.

## Related

- [[coding/typescript]]
- [[coding/typescript-strict-mode]]
- [[coding/typescript-generics]]
- [[coding/typescript-runtime-validation]]
- [[coding/typescript-utility-types]]
