Skip to lesson

learningtypescript.org / intermediate / 17-type-narrowing · lesson 17 of 25

TL;DR

Learn TypeScript type narrowing with typeof, instanceof, in operator, discriminated unions, and user-defined type guards.

Key concepts

  • TypeScript type narrowing
  • type guards TypeScript
  • instanceof TypeScript
  • user-defined type guards

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));

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.

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 && shape.type === "triangle") {
    // TypeScript knows: shape is Triangle
    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 — without the predicate, .filter() would return Pet[], leaving you stuck with the full union type.

Try It Yourself

Apply everything you've learned. This playground contains a union of three notification types — implement formatNotification so it handles each one correctly.

type EmailNotification = {
  channel: "email";
  to: string;
  subject: string;
  body: string;
};

type SMSNotification = {
  channel: "sms";
  to: string;
  message: string;
};

type PushNotification = {
  channel: "push";
  title: string;
  body: string;
  badge?: number;
};

type Notification = EmailNotification | SMSNotification | PushNotification;

function formatNotification(n: Notification): string {
  switch (n.channel) {
    case "email":
      return `[Email → ${n.to}] ${n.subject}: ${n.body}`;
    case "sms":
      return `[SMS → ${n.to}] ${n.message}`;
    case "push":
      const badgeInfo = n.badge !== undefined ? ` (badge: ${n.badge})` : "";
      return `[Push] ${n.title}: ${n.body}${badgeInfo}`;
  }
}

const email: Notification = {
  channel: "email",
  to: "user@example.com",
  subject: "Welcome!",
  body: "Thanks for signing up.",
};

const sms: Notification = {
  channel: "sms",
  to: "+15551234567",
  message: "Your code is 482910",
};

const push: Notification = {
  channel: "push",
  title: "New message",
  body: "Alice sent you a message.",
  badge: 3,
};

console.log(formatNotification(email));
console.log(formatNotification(sms));
console.log(formatNotification(push));

Try extending the Notification union with a new "webhook" channel type and add a case for it — notice how TypeScript flags any missing cases if you add an exhaustiveness check in a default branch using never.

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.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.