📖 The Story: The Shape Detective

A payment processing company handled multiple payment types: Credit Cards, PayPal, and Bank Transfers. Each had different properties. The developer used a union type:

typescript · The Setup
type Payment = CreditCard | PayPal | BankTransfer;

function processPayment(payment: Payment) {
  // How do we know if it has cardNumber or paypalemail?
  payment.cardNumber; // ❌ Error! Not all types have this!
}

TypeScript blocked access because it couldn't know which specific type payment was. The developer almost reached for as CreditCard—a dangerous assertion that would crash on PayPal payments.

Instead, they learned type narrowing. By checking for a property that only exists on one type, TypeScript automatically narrowed the type inside that condition. Safe access. Zero assertions. The code handled all three payment types without a single crash.

Narrowing is TypeScript's reasoning engine. You give it clues; it solves the puzzle.

🎯 Why Narrowing?

🛡️

Safe Property Access

Access type-specific properties without as assertions.

🧠

Compiler Intelligence

TypeScript tracks your logic flow and refines types automatically.

🔒

Exhaustive Safety

The never trick guarantees you handle every case, forever.

🧒 Explain Like I'm 10

Imagine a mystery box that could contain a toy car, a doll, or a puzzle. You can't play with it until you know what's inside.

You shake it. "It rattles!" Now you know it's not the puzzle (puzzles don't rattle). You've narrowed it down. You look through the keyhole. "I see wheels!" Now you know it's the car. You can finally play with it.

Every check you make (typeof, in, instanceof) is a clue. TypeScript uses these clues to figure out exactly what's in the box at that moment.

🎓 Professional Explanation

Type narrowing is the compiler's process of refining a variable's type from a broad union to a specific member based on control flow analysis. TypeScript analyzes your if statements, switch blocks, and ternary expressions, tracking which conditions must be true in each code path, and adjusts the type accordingly.

📊 The Narrowing Flow

How TypeScript Narrows a Union

Start: value is string | number | undefined
⬇️ if (typeof value === "string")
Inside block: value is string ✅
⬇️ (outside that block, in else)
Else block: value is number | undefined
⬇️ if (value !== undefined)
Nested block: value is number ✅

🔤 typeof Guards

The simplest narrowing tool. Works with string, number, boolean, symbol, bigint, function, and undefined.

typescript
function formatValue(value: string | number) {
  if (typeof value === "string") {
    // In here, TypeScript KNOWS value is a string
    return value.toUpperCase(); // ✅ Safe!
  }
  // Here, TypeScript KNOWS value must be a number
  return value.toFixed(2; // ✅ Safe!
}

Important: typeof null === "object" in JavaScript! Never use typeof to check for null. Use value !== null instead.

💡 Truthiness Narrowing

Checking if a value exists automatically filters out null and undefined.

typescript
function printLength(str: string | null | undefined) {
  if (str) {
    console.log(str.length); // ✅ TS knows str is a string here
  } else {
    console.log("No string provided");
  }
}

Watch out: Truthiness also filters out empty strings (""), zero (0), and false. If you only want to exclude null/undefined, use if (str !== null && str !== undefined) or the shorter if (str != null).

🔍 The in Operator

The in operator checks if a property exists on an object. Perfect for narrowing object unions.

typescript
interface Bird { fly: () => void; }
interface Fish { swim: () => void; }

function move(animal: Bird | Fish) {
  if ("swim" in animal) {
    animal.swim(); // ✅ TS knows it's a Fish!
  } else {
    animal.fly(); // ✅ TS knows it's a Bird!
  }
}

🏗️ instanceof Narrowing

For classes, instanceof narrows to the class type.

typescript
function logError(error: Error | string) {
  if (error instanceof Error) {
    console.log(error.message); // ✅ It's an Error object
  } else {
    console.log(error.toUpperCase()); // ✅ It's a string
  }
}

🎯 Custom Type Predicates

This is the crown jewel of narrowing. When built-in guards aren't enough, write your own with the is keyword.

typescript
interface Cat { meow(): void; }
interface Dog { bark(): void; }

// The return type 'animal is Cat' is a TYPE PREDICATE
function isCat(animal: Cat | Dog): animal is Cat {
  return (animal as Cat).meow !== undefined;
}

function makeSound(animal: Cat | Dog) {
  if (isCat(animal)) {
    animal.meow(); // ✅ TS trusts your predicate!
  } else {
    animal.bark(); // ✅ Must be a Dog!
  }
}

The power: The return type animal is Cat tells TypeScript: "If this function returns true, treat animal as a Cat." This lets you encapsulate complex checking logic in one reusable function.

🏷️ Discriminated Unions (Best Practice)

The cleanest narrowing pattern. Give every variant a literal "tag" field.

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number }
  | { kind: "rectangle"; width: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // ✅ .radius exists!
    case "square":
      return shape.sideLength ** 2; // ✅ .sideLength exists!
    case "rectangle":
      return shape.width * shape.height; // ✅ Both exist!
  }
}

Why this is king: The kind field acts as a "discriminant." Switching on it instantly narrows to the exact shape. No assertions, no checks, no ambiguity. This is how Redux, React's useState, and most API responses are modeled.

✅ Exhaustive Checking with never

The most powerful trick in TypeScript. Guarantee you've handled every possible case—even future ones.

typescript
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      // If we've handled all cases, shape here is 'never'
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

// Later, someone adds 'triangle' to Shape...
// TypeScript INSTANTLY errors:
// Type '"triangle"' is not assignable to type 'never'.
// You're forced to add the triangle case! Future-proof!

How it works: If all cases are handled, shape in the default branch has no remaining type, so it becomes never. Assigning never to never is fine. But if a new variant is added, TypeScript tries to assign it to never and fails, forcing you to update the switch.

⚠️ Common Mistakes

Mistake 1: Narrowing Lost in Callbacks

typescript · Bad
function processValue(value: string | number) {
  if (typeof value === "string") {
    // ✅ value is string here...
    setTimeout(() => {
      value.toUpperCase(); // ❌ ERROR! Narrowing lost!
      // TS can't guarantee value wasn't reassigned by the callback time
    }, 100);
  }
}

Fix: Assign to a new const before the callback: const str = value; then use str inside. TypeScript narrows const permanently.

Mistake 2: Using = instead of === in guards

typescript · Bad
// ❌ Single = is assignment, not comparison! No narrowing!
if (typeof value = "string") { // Syntax error!

// ✅ Always use ===
if (typeof value === "string") { // Perfect!

✅ Best Practices

  1. Always use discriminated unions for state management (loading/success/error).
  2. Always add the exhaustive never check in switch statements over unions.
  3. Extract complex checks into type predicates (function isCat(x): x is Cat).
  4. Use !== null instead of typeof !== "object" to check for null.
  5. Assign narrowed values to const before using in closures.

🏗️ Senior Deep Dive

Control Flow Analysis Internals

TypeScript's narrowing is powered by Control Flow Analysis (CFA). The compiler builds a graph of your code's execution paths. At each node, it computes the "reachable type"—which union members could possibly reach that point. When you write if (typeof x === "string") return;, the code after the if can only be reached by non-strings, so TypeScript eliminates string from the union there.

This is why narrowing sometimes "fails" in async callbacks: TypeScript can't know when the callback runs relative to other code, so it conservatively widens the type back.

Assertion Functions

A lesser-known alternative to type predicates—assertion functions:

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

function process(input: unknown) {
  assertIsString(input);
  input.toUpperCase(); // ✅ TS knows it's a string now!
}

Difference from predicates: Assertion functions throw if the check fails (halting execution). Predicates return a boolean (allowing else branches). Use assertions for "must be true or die" scenarios, predicates for branching logic.

💼 Interview Questions

Beginner
What is type narrowing?
Show Answer
Type narrowing is TypeScript's ability to refine a variable's type from a broader union to a specific member, based on conditional checks like typeof, in, or instanceof within control flow blocks.
Intermediate
What is a custom type predicate and how do you write one?
Show Answer
A type predicate is a function whose return type is value is Type instead of boolean. When it returns true, TypeScript narrows the argument to that type. Example: function isCat(a: Animal): a is Cat { return a.meow !== undefined; }
Advanced
Explain the exhaustive checking pattern with the never type.
Show Answer
In a switch statement's default case, assign the value to a never typed variable. If all union cases are handled, the value's type in default is never, so the assignment succeeds. If a new variant is added later, TypeScript errors because the new type can't be assigned to never, forcing you to handle it.
Scenario
Your API returns { status: "success", data } or { status: "error", message }. How do you safely handle this?
Show Answer
Use a discriminated union with status as the tag. Check if (response.status === "success") to access data safely, or check error to access message. TypeScript narrows automatically based on the discriminant.

🏋️ Practical Exercise

🎯 Hands-On Practice Medium

Build a Type Guard for API Responses

Write a custom type predicate isSuccessResponse that checks if an API response is a success:

typescript · Goal
type ApiResponse =
  | { success: true; data: string[] }
  | { success: false; error: string };

// Write this function using the 'is' keyword
function isSuccessResponse(resp: ApiResponse): /* ??? */ {
  // Your implementation here
}

Task: Complete the type predicate and use it to write a function that returns data.length on success or the error message on failure.

📝 Quiz

Test your understanding. Click an option to check your answer.

1. What does typeof null return in JavaScript?
A) "null"
B) "object"
C) "undefined"
Answer: B — This is a famous JavaScript bug! Always use value !== null instead of typeof to check for null.
2. Which operator checks if a property exists on an object for narrowing?
A) has
B) in
C) exists
Answer: B — if ("swim" in animal) narrows the type based on property existence.
3. What keyword is used in a custom type predicate's return type?
A) is
B) as
C) type
Answer: A — function isCat(x): x is Cat tells TypeScript to narrow when the function returns true.
4. What is a "discriminant" in a discriminated union?
A) A private property
B) A shared literal field (like kind or status) used to narrow
C) A generic parameter
Answer: B — The discriminant is a common field with unique literal values per variant, enabling instant narrowing.
5. Why does narrowing break inside setTimeout callbacks?
A) setTimeout is asynchronous
B) TypeScript can't guarantee the variable hasn't changed by callback time (mutable let/parameters)
C) It's a TypeScript bug
Answer: B — TypeScript conservatively widens types in closures. Fix: assign to a const before the callback.
6. What does assigning to never in a default case accomplish?
A) Makes the function return never
B) Ensures exhaustive checking — errors if any union member is unhandled
C) Disables TypeScript
Answer: B — If all cases are handled, the value is never. If a new variant exists, it can't be assigned to never, causing a compile error.

📦 Summary

🎯 Key Takeaways

  • TypeScript narrows types automatically based on control flow (if/switch).
  • Six narrowing tools: typeof, truthiness, in, instanceof, type predicates, discriminants.
  • Discriminated unions (tagged with kind/status) are the best practice for state.
  • Custom predicates (x is Type) encapsulate complex checks into reusable functions.
  • The never trick makes switch statements future-proof against new variants.
  • Narrowing breaks in callbacks — use const to preserve it.