Skip to editor content
learningtypescript.orglesson 8 of 25

Type Narrowing

You have a value typed as string | number | null. Before you can call .toUpperCase() on it, TypeScript needs proof that it's actually a string. That proof is called type narrowing — the process of refining a broad type into something more specific based on runtime checks. TypeScript's control flow analysis watches your conditionals and automatically tracks which types are possible at each point in your code.

The typeof Guard

The most basic form of narrowing uses JavaScript's typeof operator. TypeScript understands typeof checks and uses them to narrow primitive types.

function formatValue(value: string | number | boolean): string {
  if (typeof value === "string") {
    // TypeScript knows: value is string here
    return value.toUpperCase();
  }

  if (typeof value === "number") {
    // TypeScript knows: value is number here
    return value.toFixed(2);
  }

  // TypeScript knows: value is boolean here
  return value ? "Yes" : "No";
}

console.log(formatValue("hello"));  // "HELLO"
console.log(formatValue(3.14159)); // "3.14"
console.log(formatValue(true));    // "Yes"
console.log(formatValue(false));   // "No"

Notice there's no else branch needed — TypeScript tracks which types have already been handled and narrows the remaining possibilities automatically. This is called control flow analysis.

Truthiness Narrowing and Nullish Checks

When a value can be null or undefined, a simple truthiness check narrows it away. This is the most common pattern you'll write in real code.

interface User {
  name: string;
  bio: string | null;
  website?: string;
}

function renderProfile(user: User): string {
  // Nullish check: bio could be null
  const bioLine = user.bio !== null
    ? `Bio: ${user.bio}`
    : "No bio provided.";

  // Truthiness check: website could be undefined
  const websiteLine = user.website
    ? `Website: ${user.website}`
    : "No website.";

  return [user.name, bioLine, websiteLine].join("\n");
}

const alice: User = { name: "Alice", bio: "Engineer at Acme", website: "https://alice.dev" };
const bob: User = { name: "Bob", bio: null };

console.log(renderProfile(alice));
console.log("---");
console.log(renderProfile(bob));

Predict

The parameter is string | null | undefined. The early return uses == null (loose equality, one equals sign short of ===). After that guard, value.toUpperCase() is called with no cast. Predict what the compiler narrowed value to on that line, and therefore what the first two logged calls print.

function shout(value: string | null | undefined): string {
if (value == null) {
  return "(nothing)";
}
// value is narrowed here — no cast used
return value.toUpperCase();
}

console.log(shout("hi"));
console.log(shout(null));

The instanceof Guard

For class instances, instanceof is the right tool. TypeScript narrows the type to the specific class inside the branch.

class NetworkError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

class ValidationError extends Error {
  field: string;
  constructor(message: string, field: string) {
    super(message);
    this.field = field;
  }
}

function handleError(error: NetworkError | ValidationError | Error): string {
  if (error instanceof NetworkError) {
    // TypeScript knows: error has statusCode
    return `Network error ${error.statusCode}: ${error.message}`;
  }

  if (error instanceof ValidationError) {
    // TypeScript knows: error has field
    return `Validation failed on "${error.field}": ${error.message}`;
  }

  // TypeScript knows: error is base Error
  return `Unexpected error: ${error.message}`;
}

console.log(handleError(new NetworkError("Not Found", 404)));
console.log(handleError(new ValidationError("Required", "email")));
console.log(handleError(new Error("Something went wrong")));

Discriminated Unions

Discriminated unions are the most powerful narrowing pattern in TypeScript. By adding a shared literal type field (often called a "tag" or "discriminant") to each member of a union, TypeScript can narrow exhaustively.

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState   = { status: "error"; message: string; retryable: boolean };

type AsyncState = LoadingState | SuccessState | ErrorState;

function renderState(state: AsyncState): string {
  switch (state.status) {
    case "loading":
      return "Loading...";

    case "success":
      // TypeScript knows: state.data exists
      return `Loaded ${state.data.length} items: ${state.data.join(", ")}`;

    case "error":
      // TypeScript knows: state.message and state.retryable exist
      const hint = state.retryable ? " (click to retry)" : "";
      return `Error: ${state.message}${hint}`;
  }
}

const loading: AsyncState  = { status: "loading" };
const success: AsyncState  = { status: "success", data: ["apple", "banana", "cherry"] };
const error: AsyncState    = { status: "error", message: "Timeout", retryable: true };

console.log(renderState(loading));
console.log(renderState(success));
console.log(renderState(error));

The switch on state.status is exhaustive — if you add a new member to the union later and forget to handle it, TypeScript will tell you at compile time.

Debug

This should total the minutes spent across the LESSON entries only, and print 50. Instead the assert fails. Nothing about the types looks wrong at a glance — but the runner strips types and runs it anyway. Predict what total actually is before running, then fix it so it prints 50.

import assert from "node:assert";

type LessonEntry = { kind: "lesson"; title: string; minutesSpent: number };
type QuizEntry = { kind: "quiz"; title: string; score: number };
type Entry = LessonEntry | QuizEntry;

const entries: Entry[] = [
{ kind: "lesson", title: "Types", minutesSpent: 30 },
{ kind: "quiz", title: "Union quiz", score: 90 },
{ kind: "lesson", title: "Narrowing", minutesSpent: 20 },
];

// Intent: total the minutes across the lesson entries only.
function totalLessonMinutes(list: Entry[]): number {
let total = 0;
for (const entry of list) {
	// The cast SILENCES the compiler instead of narrowing.
	total += (entry as LessonEntry).minutesSpent;
}
return total;
}

const total = totalLessonMinutes(entries);
assert.strictEqual(total, 50, "expected 50 total lesson minutes, got " + total);
console.log("Total lesson minutes:", total);

Expected output: Total lesson minutes: 50

The in Operator

When you don't control the type definitions (or can't add a discriminant), the in operator checks for property existence and narrows accordingly.

interface Circle {
  radius: number;
}

interface Rectangle {
  width: number;
  height: number;
}

interface Triangle {
  base: number;
  height: number;
  type: "triangle";
}

type Shape = Circle | Rectangle | Triangle;

function area(shape: Shape): number {
  if ("radius" in shape) {
    // TypeScript knows: shape is Circle
    return Math.PI * shape.radius ** 2;
  }

  if ("type" in shape) {
    // TypeScript knows: shape is Triangle (only Triangle has a `type` field)
    return 0.5 * shape.base * shape.height;
  }

  // TypeScript knows: shape is Rectangle
  return shape.width * shape.height;
}

console.log(area({ radius: 5 }).toFixed(2));                   // "78.54"
console.log(area({ width: 4, height: 6 }));                    // 24
console.log(area({ base: 3, height: 8, type: "triangle" }));   // 12

User-Defined Type Guards

Sometimes none of the built-in narrowing techniques are expressive enough. You can write your own type predicate — a function that returns value is SomeType — to teach TypeScript how to narrow.

interface Cat {
  kind: "cat";
  name: string;
  lives: number;
}

interface Dog {
  kind: "dog";
  name: string;
  breed: string;
}

type Pet = Cat | Dog;

// The return type "pet is Cat" is the type predicate
function isCat(pet: Pet): pet is Cat {
  return pet.kind === "cat";
}

function describeLifespan(pet: Pet): string {
  if (isCat(pet)) {
    // TypeScript knows: pet is Cat — pet.lives is available
    return `${pet.name} has ${pet.lives} lives.`;
  }
  // TypeScript knows: pet is Dog — pet.breed is available
  return `${pet.name} is a ${pet.breed}.`;
}

const whiskers: Pet = { kind: "cat", name: "Whiskers", lives: 9 };
const rex: Pet      = { kind: "dog", name: "Rex", breed: "German Shepherd" };

console.log(describeLifespan(whiskers));
console.log(describeLifespan(rex));

// Type guards also work in array filters
const pets: Pet[] = [whiskers, rex, { kind: "cat", name: "Luna", lives: 7 }];
const cats = pets.filter(isCat); // TypeScript infers Cat[]
console.log(`Cats: ${cats.map(c => c.name).join(", ")}`);

The pets.filter(isCat) example shows why type predicates are especially useful — the named guard states the narrowing explicitly, so .filter() hands back Cat[] rather than Pet[], and the same guard is reusable everywhere you need it. Since TypeScript 5.5 the compiler can also infer a predicate from an inline closure whose body is a recognizable narrowing check, so pets.filter(p => p.kind === "cat") gives you Cat[] too. That inference is narrow, though: annotate the parameter as (p): boolean => p.kind === "cat", or filter on something unrelated like p.name.length > 2, and you are back to Pet[] and the full union type.

Recall

Without scrolling up: the very first narrowing tool in this lesson was the typeof guard. You already met it in 04-control-flow. What does typeof value evaluate to at run time, and why does that make typeof value === 'string' usable as a type guard?

Try It Yourself

Reading about narrowing is not the same as writing a narrowing-driven report. This is a build task: a small program that reports its own pass/fail. You are given a discriminated union of three learning-entry kinds — the exact reporting shape the tracker capstone renders. Two functions are stubbed. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.

The two functions reuse exactly what this lesson taught: a switch on the discriminant that narrows each case to one member (with a never exhaustiveness check in the default), and a user-defined type guard driving a typed .filter(). The starter has the data, the stubs, and the checks — you write only the logic inside each function.

Build

Finish the build. Two functions are stubbed and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// The discriminated union is given. Do NOT change these.
type LessonEntry = { kind: "lesson"; title: string; minutesSpent: number };
type QuizEntry = { kind: "quiz"; title: string; score: number };
type PracticeEntry = { kind: "practice"; title: string; solved: boolean };
type Entry = LessonEntry | QuizEntry | PracticeEntry;

const entries: Entry[] = [
{ kind: "lesson", title: "Types", minutesSpent: 30 },
{ kind: "quiz", title: "Union quiz", score: 90 },
{ kind: "practice", title: "Narrowing drills", solved: true },
];

// TODO 1: render ONE report line by narrowing on the discriminant. Switch on
//   entry.kind; each case reads only that member's own fields. End the default
//   branch with an exhaustiveness check: `const _never: never = entry;`
//   Example of one case:  case "quiz": return `Quiz: ${entry.title} (scored ${entry.score})`;
//   describe({ kind: "lesson", title: "Types", minutesSpent: 30 })           -> 'Lesson: Types (30m)'
//   describe({ kind: "quiz", title: "Union quiz", score: 90 })               -> 'Quiz: Union quiz (scored 90)'
//   describe({ kind: "practice", title: "Narrowing drills", solved: true })  -> 'Practice: Narrowing drills (solved)'
function describe(entry: Entry): string {
// your code here
return ""; // replace this
}

// TODO 2: count how many entries are quizzes, using a type guard.
//   Write isQuiz so its return type is `entry is QuizEntry`, then filter with it
//   so .filter narrows the array to QuizEntry[].
//   countQuizzes(entries) -> 1
function isQuiz(entry: Entry): entry is QuizEntry {
// your code here
return false; // replace this
}

function countQuizzes(list: Entry[]): number {
// your code here
return 0; // replace this
}

// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(describe(entries[0]), "Lesson: Types (30m)", "TODO 1: describe should narrow and render the lesson line");
assert.strictEqual(describe(entries[1]), "Quiz: Union quiz (scored 90)", "TODO 1: describe should narrow and render the quiz line");
assert.strictEqual(describe(entries[2]), "Practice: Narrowing drills (solved)", "TODO 1: describe should narrow and render the practice line");
assert.strictEqual(countQuizzes(entries), 1, "TODO 2: countQuizzes should count only quiz entries via the type guard");

console.log("All checks passed.");
for (const entry of entries) {
console.log(describe(entry));
}
console.log("Quizzes:", countQuizzes(entries));

Expected output: All checks passed. Lesson: Types (30m) Quiz: Union quiz (scored 90) Practice: Narrowing drills (solved) Quizzes: 1

Once it passes, try two variations and predict each before running:

  1. Invert the guard's use. In countQuizzes, change list.filter(isQuiz) to list.filter((e) => !isQuiz(e)) so it keeps the non-quizzes. Predict which check fails first before running. The filter now keeps the lesson and practice entries, so countQuizzes(entries) returns 2, and TODO 2's check fires with AssertionError: TODO 2: countQuizzes should count only quiz entries via the type guard and 2 !== 1. An instructive assert failure showing that a type guard's result depends entirely on how you use its boolean.
  2. Add a fourth kind, skip its case. Add type StreakEntry = { kind: "streak"; title: string; days: number };, include it in the Entry union, and push { kind: "streak", title: "7-day streak", days: 7 } onto entries — but do NOT add a case "streak" to describe. Predict what the run prints for the streak entry, and what tsc --strict would say. Under the runner (types stripped) the streak entry falls through to the default, where const _never = entry just aliases the entry, so describe returns the entry object and the loop prints { kind: 'streak', title: '7-day streak', days: 7 } — a garbled report line, no crash. The three original checks still pass (they never touch the streak entry), so it exits 0. But tsc --strict would have caught it: error TS2322: Type 'StreakEntry' is not assignable to type 'never'. at the exhaustiveness line — the never check turning a forgotten case into a compile error the runner alone would miss.

Capstone milestone

Milestone — the report. The tracker renders its console report by narrowing each entry to one variant of a discriminated union and printing only that variant's own fields, with a never check so a forgotten kind fails to compile. The describe/countQuizzes pair you just built is that reporter in miniature. Confirm you can drive a report by narrowing rather than casting.

Hint: This is the reporting milestone, shared with Debugging TypeScript (exhaustiveness checks harden the reporter). Here narrowing per discriminated-union variant is the load-bearing part: in the capstone this same shape renders the tracker's console report, one variant at a time.

  • Switched on the discriminant so each case reads only its own variant's fields — no casts
  • Ended the switch with a never exhaustiveness check that catches a forgotten kind at compile time
  • Wrote a user-defined type guard (entry is QuizEntry) and drove a typed .filter with it
  • Rendered each entry from the fields the compiler proved it has, not from ones you assumed

Key Takeaways

  • typeof narrows primitive types: string, number, boolean, bigint, symbol, undefined, function, and object.
  • Truthiness and nullish checks (!== null, !== undefined, if (value)) eliminate null and undefined from a type.
  • instanceof narrows class instances and is ideal for handling error hierarchies.
  • Discriminated unions use a shared literal-type field so TypeScript can narrow exhaustively in a switch — prefer these for domain modelling.
  • in narrows by property presence and is useful when you can't add a discriminant to existing types.
  • User-defined type guards (value is T) teach TypeScript about custom narrowing logic and enable properly-typed .filter() calls.
  • TypeScript's control flow analysis is path-sensitive — it tracks possible types independently on each branch, so you never need to cast with as just to call a method.

Pro Tip: If you ever feel tempted to write as SomeType, stop and ask whether a discriminated union or type guard would solve the problem instead. Casts silence the compiler without giving it new information — narrowing teaches the compiler, making the rest of your code safer as a result.

Next Steps

Type narrowing works hand-in-hand with classes, where instanceof checks are the most natural way to narrow. Next, you'll learn how to define classes with access modifiers, inheritance, abstract methods, and interface contracts.

Next lesson

Classes And OOP

Learn TypeScript classes and OOP with constructors, inheritance, access modifiers, and interfaces for type-safe object-oriented code.

28 min