Skip to content

TypeScript Narrowing — Complete Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about TypeScript Narrowing. We cover key concepts, practical examples, and best practices to help you master this topic.

TypeScript narrowing is the Process of refining a broad type into a more specific one based on runtime checks — TypeScript's control flow analysis tracks assignments, conditions, loops, and exception paths to narrow types automatically within each code branch.

What You'll Learn

  • Control flow analysis basics
  • Truthiness and falsiness narrowing
  • Equality narrowing and discriminated unions
  • switch statement narrowing
  • Type predicates and assertion functions

Why It Matters

Narrowing is what makes union types practical. Without it, you'd need manual type assertions everywhere. TypeScript's automatic narrowing means you write runtime validation code and get compile-time type safety as a side effect.

Real-World Use

Durga Antivirus Pro's scan result processing uses narrowing extensively — a ScanResult can be ScanningResult | CompletedResult | FailedResult. Control flow on the status property narrows to the correct variant, ensuring that threats is only accessed when the scan completed successfully.

Learning Path

flowchart LR
  A[Type Guards] --> B[Narrowing]
  B --> C[Recursive Types]
  B --> D[You Are Here]
  C --> E[Variance]
  E --> F[Overloads]

Control Flow Analysis

TypeScript tracks how the type of a variable changes through code flow:

function example(x: string | number | boolean): void {
  // x: string | number | boolean

  if (typeof x === "string") {
    // x: string
    console.log(x.toUpperCase());
  } else if (typeof x === "number") {
    // x: number
    console.log(x.toFixed(2));
  } else {
    // x: boolean
    console.log(x ? "True" : "False");
  }

  // After the if/else, x: string | number | boolean (back to original union)
}

Think of TypeScript's narrowing as a mind-reader that follows along with your code, updating what it knows about each variable after every check.

Truthiness Narrowing

Truthy/falsy checks narrow types by removing falsy values:

function getFirstElement(arr: number[] | undefined): number | undefined {
  if (!arr) {
    return undefined;
  }
  return arr[0]; // arr: number[] (narrowed from undefined)
}

function processValue(value: string | null | undefined): string {
  if (value == null) {
    return "default";
  }
  return value.toUpperCase(); // value: string
}

Falsy Values Removed by Truthiness Checks

const falsyValues = [false, 0, "", null, undefined, NaN];

A truthiness check removes all of these from the type.

Equality Narrowing

Using ===, !==, ==, or != narrows types:

function compare(x: string | number, y: string | boolean): void {
  if (x === y) {
    // Both are string (only common type)
    console.log(x.toUpperCase(), y.toUpperCase());
  }
}

function getUser(id: string | number): void {
  if (typeof id === "string") {
    console.log(`String ID: ${id.toUpperCase()}`);
  } else {
    console.log(`Numeric ID: ${id.toFixed(0)}`);
  }
}

in Operator Narrowing

type Admin = { role: "admin"; permissions: string[] };
type User = { role: "user"; department: string };

function handle(person: Admin | User): void {
  if ("permissions" in person) {
    console.log(person.permissions.join(", ")); // Admin
  } else {
    console.log(person.department); // User
  }
}

switch Statement Narrowing

type Status =
  | { status: "idle" }
  | { status: "loading"; progress: number }
  | { status: "success"; data: string }
  | { status: "error"; message: string };

function render(status: Status): string {
  switch (status.status) {
    case "idle":
      return "Ready to start";
    case "loading":
      return `Loading... ${status.progress}%`;
    case "success":
      return `Data: ${status.data}`;
    case "error":
      return `Error: ${status.message}`;
  }
}

Discriminated Union Narrowing

The most powerful narrowing pattern — a common property (the discriminant) with literal types:

type RequestState<T> =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "success"; data: T }
  | { kind: "error"; error: string };

function handleState<T>(state: RequestState<T>): string {
  switch (state.kind) {
    case "idle":
      return "Not started";
    case "loading":
      return "Fetching...";
    case "success":
      return `Got ${JSON.stringify(state.data)}`;
    case "error":
      return `Failed: ${state.error}`;
  }
}

Assertion Functions

Functions that throw if a condition isn't met, narrowing the type afterward:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error("Value must be a string");
  }
}

function process(data: unknown): void {
  assertIsString(data);
  data.toUpperCase(); // Narrowed to string
}

Assignment Narrowing

When you reassign a variable, TypeScript narrows based on the assigned value:

let value: string | number;

value = "hello";
value.toUpperCase(); // OK — value is string

value = 42;
value.toFixed(2); // OK — value is number

// value = true; // Error: boolean is not in the union

Function Return Narrowing

function isString(value: unknown): value is string {
  return typeof value === "string";
}

function process(items: (string | number)[]): void {
  const strings = items.filter(isString); // TypeScript knows strings is string[]
  const numbers = items.filter((x): x is number => typeof x === "number");
}

Common Mistakes

1. Assuming TypeScript Follows Complex Runtime Logic

function badNarrow(value: string | null): string {
  // Bad: TypeScript doesn't track external state
  if (someExternalFlag) {
    // TypeScript still thinks value is string | null
  }
  // ...
}

TypeScript narrows based on local control flow, not external state.

2. Reassigning Variables Inside Branches

function process(value: string | number): void {
  if (typeof value === "string") {
    // value: string
    value = 42; // Reassign to number — now value: number
  }
  // value: number (the string path ended with reassignment)
}

3. Using == null vs === null || === undefined

value == null catches both null and undefined. === checks only one.

4. Forgetting Truthiness Doesn't Remove Empty String

function logMessage(msg: string | null): void {
  if (msg) {
    // msg: string — but "" is falsy!
    console.log(msg.toUpperCase()); // Empty string doesn't crash
  }
}

5. Not Using asserts Functions for Early Exits

Instead of nested ifs, use assertion functions for cleaner narrowing:

function process(data: unknown): void {
  if (typeof data !== "object" || data === null) throw new Error();
  if (!("name" in data)) throw new Error();
  // data is now narrowed
}

Practice Questions

  1. What is control flow analysis in TypeScript? TypeScript's ability to track type changes through a function's execution path — assignments, conditions, loops, and exception handlers.

  2. How does truthiness narrowing work? A truthy check removes falsy values (false, 0, "", null, undefined, NaN) from the union.

  3. What is a discriminated union? A union type where each member has a common property with a literal type, enabling narrowing via switch/case or if/else on that property.

  4. What does asserts value is Type do? It's an assertion function return type that narrows the value after the function returns normally (or throws if the condition fails).

Challenge: Create a discriminated union for a file processing pipeline with states: uploading, processing, completed, failed. Write a function that takes the current state and returns appropriate UI status text.

FAQ

Does narrowing work with loops?

Yes. TypeScript narrows types within loop bodies based on loop conditions and array methods.

Can TypeScript narrow based on function calls?

Only if you use type predicates (value is Type return type) or assertion functions (asserts value is Type).

What happens if I narrow a variable and then an async operation changes it?

TypeScript doesn't track async mutations. The narrowed type is only guaranteed within the synchronous execution branch.

Does narrowing work with destructuring?

Yes: const { kind, data } = state; if (kind === "success") { data // narrowed }

Can I narrow a type based on array length?

Yes: if (arr.length > 0) { arr[0] // narrowed }

Mini Project: Async Request State Machine

// src/request-state.ts

type RequestState<T> =
  | { status: "idle" }
  | { status: "pending" }
  | { status: "success"; data: T; timestamp: Date }
  | { status: "error"; error: string; code?: number };

class RequestManager<T> {
  private state: RequestState<T> = { status: "idle" };

  getState(): RequestState<T> {
    return this.state;
  }

  async execute(fetchFn: () => Promise<T>): Promise<void> {
    this.state = { status: "pending" };

    try {
      const data = await fetchFn();
      this.state = { status: "success", data, timestamp: new Date() };
    } catch (err) {
      this.state = {
        status: "error",
        error: err instanceof Error ? err.message : "Unknown error",
      };
    }
  }

  render(): string {
    const state = this.state;

    switch (state.status) {
      case "idle":
        return "Click to load data";

      case "pending":
        return "Loading...";

      case "success":
        return `Loaded ${JSON.stringify(state.data)} at ${state.timestamp.toISOString()}`;

      case "error":
        return `Error${state.code ? ` [${state.code}]` : ""}: ${state.error}`;
    }
  }
}

const manager = new RequestManager<string>();
console.log(manager.render()); // Click to load data

manager.execute(async () => {
  await new Promise(r => setTimeout(r, 100));
  return "Scan results: clean";
}).then(() => {
  console.log(manager.render()); // Loaded "Scan results: clean" at ...
});

What's Next

Now explore recursive types for self-referential data:

Lesson Description
{{< ref "/programming-languages/typescript/25-type-guards" >}} Review type guards
{{< ref "/programming-languages/typescript/27-recursive-types" >}} Self-referential types, JSON types
{{< ref "/programming-languages/typescript/28-variance" >}} Covariance, contravariance

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro