← back to section

Often a variable can hold a value of one of several types: a string or a number, an object or null, a success or an error. Type narrowing is how TypeScript understands, inside a block of code, which specific type it is dealing with right now and allows you to work with it safely.

Why narrow types

Suppose a function accepts a value that can be either a string or a number:

function printId(id: string | number) {
  // id.toUpperCase() — error: number has no toUpperCase
  console.log(id);
}

TypeScript won't let you call id.toUpperCase() because id might be a number. To gain access to string methods, you first need to prove to the compiler that in this branch id is definitely a string. That is narrowing: a runtime check that TypeScript knows how to read and apply to types.

Short rule: checked a value with a condition — inside the branch the type is narrowed.

typeof — for primitives

The typeof operator returns a string with the type name of the value. TypeScript understands such checks and narrows the type in each branch:

function printId(id: string | number) {
  if (typeof id === "string") {
    // here id: string — string methods are available
    console.log(id.toUpperCase());
  } else {
    // here id: number
    console.log(id.toFixed(2));
  }
}

typeof distinguishes primitives: "string", "number", "boolean", "bigint", "symbol", "undefined", "function", and "object". An important gotcha: typeof null === "object" — filtering out null requires a separate check (see below).

instanceof — for classes

When a value can be an instance of a class, instanceof helps. It checks the prototype chain and also narrows the type:

function describe(value: Date | string) {
  if (value instanceof Date) {
    // here value: Date
    return value.toISOString();
  }
  // here value: string
  return value.trim();
}

instanceof works with things created via new: the built-ins Date, Error, Map, as well as your own classes.

The in operator — by property presence

If types differ by their set of fields, you can check for a property's existence using the in operator:

interface Cat { meow(): void }
interface Dog { bark(): void }

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

This is handy when you have interfaces with no common class — instanceof doesn't apply to them, but in works.

Null and undefined checks

null and undefined are a frequent source of errors. TypeScript narrows the type here too:

function greet(name: string | null) {
  if (name === null) {
    return "Hello, guest";
  }
  // here name: string
  return `Hello, ${name.toUpperCase()}`;
}

A simple truthiness check filters out both null and undefined and an empty string at once:

function length(text?: string) {
  if (!text) {
    return 0; // text is undefined or empty string here
  }
  return text.length; // text: string
}

A useful pattern is an early return: filter out the "empty" case at the top, then work with a guaranteed valid value below.

User-defined type guards (is)

Sometimes the check is more complex than a single operator, and you want to extract it into a separate function. For TypeScript to continue narrowing the type after such a function, it must return a type predicate — an expression of the form parameter is Type:

interface User {
  id: number;
  email: string;
}

// guard function: returns not just boolean, but "value is User"
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "email" in value
  );
}

function handle(payload: unknown) {
  if (isUser(payload)) {
    // here payload: User
    console.log(payload.email);
  }
}

The value is User syntax tells the compiler: "if this function returned true, treat the argument as type User". This lets you reuse a non-trivial check and preserve the narrowing.

Discriminated unions

The most reliable pattern for objects is a discriminated union. Each variant has a common tag field (a literal type) by which TypeScript unambiguously distinguishes variants:

type Result =
  | { status: "ok"; data: string }
  | { status: "error"; message: string };

function render(result: Result) {
  switch (result.status) {
    case "ok":
      // here result: { status: "ok"; data: string }
      return result.data;
    case "error":
      // here result: { status: "error"; message: string }
      return result.message;
  }
}

The status field here is the discriminant. Once checked, TypeScript immediately knows what other fields are available: in the "ok" branch there is data, in the "error" branch there is message. This is safer than checking for fields one by one.

A bonus — exhaustiveness checking. If you add a third variant to Result and forget to handle it, the compiler will warn you via the never trick:

function render(result: Result): string {
  switch (result.status) {
    case "ok":
      return result.data;
    case "error":
      return result.message;
    default:
      // if all variants are handled, never is reached here
      const exhaustive: never = result;
      return exhaustive;
  }
}

Working with unknown

The unknown type is "safe any". It means "a value exists, but the type is unknown", and TypeScript forbids any operation on it until the type is narrowed. This is what makes it the right choice for external data: network responses, JSON parsing, user input.

function parse(json: string) {
  const data: unknown = JSON.parse(json); // JSON.parse returns any, we fix it as unknown

  // data.id — error: type is unknown
  if (isUser(data)) {
    console.log(data.id); // here data: User
  }
}

Unlike any, unknown forces you to check the value first — meaning a type error surfaces at compile time, not at runtime. Short rule: accept external data as unknown and narrow it with a guard before use.

Why narrowing makes code safe

Every narrowing check works on two levels at once. At runtime it is an ordinary condition. At compile time TypeScript uses the same check to forbid invalid operations in each branch. That's why "undefined has no such method" and similar failures are caught before running: the compiler simply won't let you access a field that might not exist in this branch.

In short

  • Type narrowing is how TypeScript understands the specific type of a value from a union inside a branch.
  • typeof narrows primitives, instanceof narrows class instances, in narrows by property presence, comparison with null/undefined filters out "empty" values.
  • Remember the gotcha typeof null === "object"null needs a separate check.
  • A user-defined type guard returning value is Type reuses a complex check and preserves the narrowing.
  • A discriminated union with a discriminant field is the most reliable way to distinguish object variants; the never trick gives exhaustiveness checking.
  • Accept external data as unknown and narrow it with a guard — the compiler will force you to check the type before use.
  • Basic type system — unions, literal types and the building blocks we narrow.
  • Generics — generic types that often combine with type guards.
  • Async and the event loop — where unknown is especially useful when parsing network responses.