TypeScript · Lesson 4 of 12

Narrowing, Type Guards and Discriminated Unions

Learn TypeScript narrowing with typeof, instanceof, in, custom type guards, assertion functions and exhaustive discriminated unions using never.

  • Intermediate
  • 16 min read
  • 3 objectives

Before this lessonLesson 3: Typing Functions and Overloads

What you will learn

  • Narrow unions with typeof, instanceof, in and equality checks
  • Write custom type guards and assertion functions
  • Make switches exhaustive with never

Your Progress

0 of 12 lessons 0%

  • Lessons0 / 12
  • Completed0
  • Est. time left~ 3 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

A union type like string | number says a value could be one of several things. Before you can do anything specific with it, you have to find out which one it is. Narrowing is how TypeScript follows your runtime checks (if, switch, early returns) and refines the type inside each branch.

You met basic narrowing and discriminated unions in the types lesson. Here we go further: every built-in narrowing tool, how to write your own, and how to make the compiler force you to handle every case.

Control flow analysis

TypeScript reads your code the way you do. After an if that returns, it knows the rest of the function only runs in the other case. This is called control flow analysis, and it works with typeof, truthiness and equality checks.

function describe(value: string | number | null): string {
  if (value === null) return "nothing";   // value: null
  if (typeof value === "number") {
    return value.toFixed(2);              // value: number
  }
  return value.trim();                    // value: string (the only option left)
}

The typeof operator returns one of a few strings at runtime: "string", "number", "boolean", "bigint", "symbol", "undefined", "function" and "object". You can run this to see them:

const samples = ["hi", 42, true, 10n, undefined, () => 1, { a: 1 }, [1, 2], null];
for (const s of samples) {
  console.log(typeof s);
}
Output
string
number
boolean
bigint
undefined
function
object
object
object

instanceof and in

For class instances, instanceof narrows to the class. For plain objects (which have no class), the in operator checks whether a property exists and narrows to the union members that have it.

function errorMessage(err: unknown): string {
  if (err instanceof Error) return err.message;   // err: Error
  if (typeof err === "string") return err;
  return "Unknown error";
}

interface Card { cardNumber: string; }
interface Upi { upiId: string; }

function paymentLabel(method: Card | Upi): string {
  if ("upiId" in method) {
    return `UPI ${method.upiId}`;                 // method: Upi
  }
  return `Card ending ${method.cardNumber.slice(-4)}`; // method: Card
}

The errorMessage function is worth memorising. In a catch block the error is unknown (under strict mode), because JavaScript lets you throw anything, and this is the standard way to get a message out safely.

Custom type guards

Built-in checks cannot express everything. When you have a reusable check, write a function whose return type is a type predicate: value is SomeType. When it returns true, TypeScript narrows the argument at the call site.

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

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value && typeof value.id === "number" &&
    "name" in value && typeof value.name === "string"
  );
}

const data: unknown = JSON.parse('{"id": 1, "name": "Ada"}');
if (isUser(data)) {
  console.log(data.name.toUpperCase());   // data: User
}

// Type guards also work with filter to drop null/undefined
const maybe: (User | null)[] = [{ id: 1, name: "Ada" }, null];
const users = maybe.filter((u): u is User => u !== null);   // User[]

Since TypeScript 5.5, simple filters like maybe.filter((u) => u !== null) infer the predicate automatically, so the explicit u is User is often unnecessary. Writing it out still helps when the check is more complex.

Assertion functions

An assertion function throws instead of returning a boolean. Its return type asserts value is T tells TypeScript that if the function returns at all, the value has that type for the rest of the scope. They are handy for preconditions at the top of a function.

function assertDefined<T>(value: T, msg: string): asserts value is NonNullable<T> {
  if (value === null || value === undefined) throw new Error(msg);
}

function sendInvoice(email: string | undefined) {
  assertDefined(email, "Customer has no email");
  return email.toLowerCase();   // email: string from here on
}

Discriminated unions and exhaustiveness

The most useful narrowing pattern is the discriminated union: each variant has a shared literal field (often type, kind or status). Switching on that field narrows to exactly one variant. Add a never check in the default branch and the compiler will fail the build if someone adds a new variant but forgets to handle it.

type Event =
  | { type: "signup"; email: string }
  | { type: "purchase"; orderId: string; amount: number }
  | { type: "refund"; orderId: string };

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

function summarize(e: Event): string {
  switch (e.type) {
    case "signup":   return `New user ${e.email}`;
    case "purchase": return `Order ${e.orderId}: $${e.amount}`;
    case "refund":   return `Refund for ${e.orderId}`;
    default:         return assertNever(e);   // e: never
  }
}

Why does this work? After the three cases, every possible variant has been removed from the union, so e has the type never (a type with no values). If you add { type: "cancel" } to Event, e in the default branch becomes that variant, which is not assignable to never, and you get a compile error pointing at the exact switch you need to update.

Here is the runtime behaviour in JavaScript, including what happens when unexpected data slips through:

function summarize(e) {
  switch (e.type) {
    case "signup":   return `New user ${e.email}`;
    case "purchase": return `Order ${e.orderId}: $${e.amount}`;
    case "refund":   return `Refund for ${e.orderId}`;
    default: throw new Error(`Unhandled case: ${JSON.stringify(e)}`);
  }
}

const events = [
  { type: "signup", email: "ada@stackcone.com" },
  { type: "purchase", orderId: "A1", amount: 49 },
  { type: "refund", orderId: "A1" },
  { type: "cancel", orderId: "A2" },
];
for (const e of events) {
  try {
    console.log(summarize(e));
  } catch (err) {
    console.log("Error:", err.message);
  }
}
Output
New user ada@stackcone.com
Order A1: $49
Refund for A1
Error: Unhandled case: {"type":"cancel","orderId":"A2"}

Recap

  • TypeScript narrows unions by following if, switch, early returns and equality checks.
  • Use typeof for primitives, instanceof for classes and in for plain object shapes; remember typeof null is "object".
  • Custom type guards (value is T) and assertion functions (asserts value is T) package reusable checks, but TypeScript trusts them blindly.
  • In catch, the error is unknown; narrow with instanceof Error.
  • Discriminated unions plus an assertNever default make switches exhaustive and future-proof.
// Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 5Generics and Utility TypesWrite reusable typed functions and transform types with Partial, Pick and more.