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.

What You'll Learn

By the end of this lesson you will write a report function that takes a union of activity types and produces one line per kind — and, more importantly, you will be able to read the compiler error you get when you forget to check the discriminant first. That error is the one you will meet most often in TypeScript, and it has a fixed shape you can learn to decode.

This is the milestone the tracker's reporting layer stands on: the report switches on each activity's kind tag and the compiler proves you handled every case. You arrive here able to model those activities as a discriminated union (Enums And Constants gave you the literal-union tags, Interfaces and Types the shapes they tag); what is new is that the compiler now tracks which member you are holding at each point in the code, and refuses the reads that member cannot support.

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.

Reading the error you will meet most (a no-run demo)

Forgetting to check the discriminant before reading a member's field is the most common TypeScript error there is, and it always arrives in the same shape. This fence is marked no-run because its whole point is to fail to compile: the runner strips the types and would run the unguarded read happily, which would hide the very error the lesson is about.

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

function describe(entry: Entry): string {
  return `${entry.title}: ${entry.minutesSpent}m`;
}

console.log(describe({ kind: "lesson", title: "Types", minutesSpent: 30 }));

Under tsc --strict this fails with:

error TS2339: Property 'minutesSpent' does not exist on type 'Entry'.
  Property 'minutesSpent' does not exist on type 'QuizEntry'.

Read it in three steps, and this works for every narrowing error you will ever get:

  1. Read the first line for what you tried to do. You read minutesSpent off a value whose type is Entry. Note that entry.title on the same line was fine — title is on every member, so it needs no narrowing.
  2. Read the second line for the member you did not account for. The compiler names it: QuizEntry. That indented line is the whole diagnosis. The read is not wrong in general — it is wrong for one specific member of the union, and the compiler tells you which.
  3. Find the guard that eliminates that member, then re-read. A check on the discriminant does it: inside if (entry.kind === "lesson") the union no longer contains QuizEntry, so the read is legal. Re-run the compiler and the error should be gone; if it is not, you eliminated a different member than the one it named.

The runtime shadow shows why the compiler bothers. Silence the error with a cast instead of a guard, and the same code runs to completion:

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

function describe(entry: Entry): string {
  return `${entry.title}: ${(entry as LessonEntry).minutesSpent}m`;
}

console.log(describe({ kind: "lesson", title: "Types", minutesSpent: 30 }));
console.log(describe({ kind: "quiz", title: "Union quiz", score: 90 }));

That prints Types: 30m and then Union quiz: undefinedm, and exits successfully. The quiz has no minutesSpent, so the read gives undefined and it lands in the output as text. The compile error was the only thing standing between you and a report full of undefined — which is exactly the bug in the block below.

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.
// TODO: that signature is the new idea, so it is given to you. The BODY is an
// ordinary discriminant check of the kind the fence above already used.
// Replace the false with the comparison that is true exactly when pet is a
// Cat. Until you do, the output reads "Whiskers is a undefined." and the cat
// list comes back empty — the predicate is trusted, so a wrong body silently
// mis-narrows.
function isCat(pet: Pet): pet is Cat {
  return false;
}

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 body you were asked to supply is return pet.kind === "cat"; — the same discriminant comparison the earlier fences made, with the predicate return type carrying its result into the type system. Note what the wrong body did not do: it did not fail to compile. A predicate is a promise the compiler takes at face value, so the only thing that catches a wrong one is running it.

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 Control Flow. What does typeof value evaluate to at run time, and why does that make typeof value === 'string' usable as a type guard?

Arrange the code

These five lines take an unknown payload, pin down its shape, narrow it on the discriminant to pull out a number, build a summary line from it, and log it. The pieces are shuffled. Put them back in an order that runs, then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?

  1. const summary = `lesson: ${minutes} minutes`;
  2. console.log(summary);
  3. const minutes = entry.kind === "lesson" ? entry.minutesSpent : 0;
  4. const entry = payload as Entry;
  5. const payload: unknown = { kind: "lesson", minutesSpent: 45 };

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

Transfer

A TypeScript discriminated union tags each member with a literal kind field, and a switch on that tag narrows to one member per case. Rust writes the same idea as an enum whose variants carry their own data, and reads it with match. Both refuse to let you read a field the value might not have, and both can prove you covered every case. Where do the two genuinely differ?

// TypeScript
type Entry =
| { kind: "lesson"; title: string; minutesSpent: number }
| { kind: "quiz"; title: string; score: number };
// the tag is a real field on a real object, present at run time

// Rust
// enum Entry {
//     Lesson { title: String, minutes_spent: u32 },
//     Quiz { title: String, score: u32 },
// }
// the variant is the value's identity; there is no separate tag field to read

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.

Ready to continue? Head to Classes and OOP!

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