Type Narrowing & Type Guards

Reviewed & published by Brayan K

Type narrowing is how TypeScript refines a broad type into a more specific one inside a block — using runtime checks like typeof, instanceof, and custom type guards — so you can use the value safely.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

A union type is like a parcel labelled "fragile or heavy". You can't decide how to lift it until you check which it is. Narrowing is that check at the door: once you confirm "this one's fragile", you handle it with care for the rest of the journey. TypeScript watches your checks and updates what it knows about the parcel inside each branch.

1. typeof & Truthiness Narrowing

The simplest narrowing tool is typeof, which distinguishes the primitive types ("string", "number", "boolean", …). Inside if (typeof x === "number"), TypeScript knows x is a number. A bare truthiness check like if (name) is just as useful: it removes null, undefined, and "" from the type, so the value inside the block is guaranteed present.

// TypeScript automatically narrows inside each branch:
function double(x: number | string): number | string | null {
  if (typeof x === "number") return x * 2;   // x: number here
  if (typeof x === "string") return x + x;   // x: string here
  return null;
}

// Truthiness narrowing removes null/undefined from a union:
function greet(name: string | null | undefined): string {
  if (name) return "Hi, " + name; // name: string inside the if
  return "Hi, stranger";
}
// NARROWING = convincing TypeScript that a broad value is something more
// specific INSIDE a block, so you can use it safely.
// The everyday tools are: typeof, truthiness checks, instanceof, and 'in'.

// --- typeof narrowing (for primitives) ---
function double(x) {
  if (typeof x === "number") {
    return x * 2;                 // x is a number here
  }
  if (typeof x === "string") {
    return x + x;                 // x is a string here (repeat it)
  }
  return null;
}
console.log(double(21));      // 42
console.log(double("ab"));    // abab
console.log(double(true));    // null

// --- truthiness narrowing (rules out null / undefined / "") ---
function greet(name) {
  if (name) {                   // skips null, undefined, and ""
    return "Hi, " + name;
  }
  return "Hi, stranger";
}
console.log(greet("Sam"));     // Hi, Sam
console.log(greet(""));        // Hi, stranger
console.log(greet(null));      // Hi, stranger

// In a .ts file the parameter might be typed:  x: number | string | boolean

2. instanceof & in Narrowing

For class instances, instanceof narrows to the class: inside if (animal instanceof Cat) you can call Cat-only methods. For plain object shapes that don't share a class, the in operator checks for a distinguishing property — "radius" in shape tells a circle from a rectangle and narrows the union accordingly.

// instanceof narrows class instances:
function describe(animal: Dog | Cat): string {
  if (animal instanceof Cat) return animal.purr(); // animal: Cat
  return animal.speak();                            // animal: Dog
}

// 'in' narrows object shapes by a distinguishing property:
type Circle = { radius: number };
type Rect = { width: number; height: number };
function area(shape: Circle | Rect): number {
  if ("radius" in shape) return Math.PI * shape.radius ** 2; // shape: Circle
  return shape.width * shape.height;                          // shape: Rect
}
// --- instanceof narrowing (for class instances) ---
class Dog { speak() { return "Woof"; } }
class Cat { speak() { return "Meow"; } purr() { return "prrr"; } }

function describe(animal) {
  if (animal instanceof Dog) {
    return "Dog says " + animal.speak();   // animal: Dog here
  }
  if (animal instanceof Cat) {
    return "Cat says " + animal.speak() + " (" + animal.purr() + ")"; // animal: Cat
  }
  return "unknown";
}
console.log(describe(new Dog())); // Dog says Woof
console.log(describe(new Cat())); // Cat says Meow (prrr)

// --- 'in' narrowing (does this property exist on the object?) ---
function area(shape) {
  if ("radius" in shape) {
    return Math.PI * shape.radius * shape.radius; // shape has radius -> circle
  }
  return shape.width * shape.height;              // otherwise -> rectangle
}
console.log(area({ radius: 2 }).toFixed(2));        // 12.57
console.log(area({ width: 3, height: 4 }));         // 12

// In a .ts file:
//   function describe(animal: Dog | Cat): string { ... }
//   function area(shape: { radius: number } | { width: number; height: number }) { ... }

3. Custom Type Guards & Discriminants

When the built-in checks aren't enough, write your own type guard: a function whose return type is the predicate arg is SomeType. When it returns true, TypeScript narrows the argument to that type wherever you called it. The most ergonomic pattern of all is a discriminant check — switching on a shared literal field (kind) so each branch knows the exact member.

type Fish = { swim: () => string; name: string };
type Bird = { fly: () => string; name: string };

// Custom type guard — note the 'pet is Fish' return type:
function isFish(pet: Fish | Bird): pet is Fish {
  return typeof (pet as Fish).swim === "function";
}

function move(pet: Fish | Bird): string {
  if (isFish(pet)) return pet.swim(); // pet: Fish here
  return pet.fly();                   // pet: Bird here
}
// A CUSTOM TYPE GUARD is a function whose return type is  "x is Foo".
// In TypeScript:  function isCat(a: Dog | Cat): a is Cat { ... }
// When it returns true, TypeScript treats the argument as that type.
// At runtime the guard is just a boolean-returning function:

function isFish(pet) {
  // The real check: does it have a 'swim' method?
  return typeof pet.swim === "function";
}

const fish = { swim: () => "glub", name: "Nemo" };
const bird = { fly: () => "flap", name: "Tweety" };

function move(pet) {
  if (isFish(pet)) {
    return pet.name + " swims: " + pet.swim();  // treated as a Fish
  }
  return pet.name + " flies: " + pet.fly();      // treated as a Bird
}
console.log(move(fish)); // Nemo swims: glub
console.log(move(bird)); // Tweety flies: flap

// --- discriminant check: switch on a shared literal 'kind' field ---
function summarise(event) {
  switch (event.kind) {
    case "click": return "clicked at (" + event.x + "," + event.y + ")";
    case "key":   return "pressed " + event.key;
  }
}
console.log(summarise({ kind: "click", x: 10, y: 20 })); // clicked at (10,20)
console.log(summarise({ kind: "key", key: "Enter" }));   // pressed Enter

// In a .ts file:  function isFish(pet: Fish | Bird): pet is Fish { ... }

🎯 Your Turn

Write len, which returns the length of a string or array and 0 for anything else. Fill in the two blanks marked ___, then run it.

// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// 'len' returns the length of a string OR an array (both have .length),
// but should return 0 for anything else. Narrow with typeof and Array.isArray.

function len(value) {
  if (typeof value === ___) {       // 👉 the string "string"
    return value.length;            // a string's length
  }
  if (Array.isArray(value)) {
    return value.___;               // 👉 also .length
  }
  return 0;                         // not a string or array
}

console.log(len("hello"));          // 5
console.log(len([1, 2, 3]));        // 3
console.log(len(42));               // 0

// ✅ Expected output:
//    5
//    3
//    0

Common Errors (and the fix)

Pro Tips

Mini-Challenge: Handle an API Result

No blanks this time — just a brief and a starting outline. Write the type guard and the handler yourself, run it, and check your output against the example in the comments.

// 🎯 MINI-CHALLENGE: a custom type guard + discriminant narrowing
// You receive an "API result" that is ONE of:
//   { ok: true,  data: string }
//   { ok: false, error: string }
//
// 1. Write isSuccess(res) that returns true when res.ok is true
//    (this is your runtime type guard).
// 2. Write handle(res): if isSuccess(res) return "Got: " + res.data,
//    otherwise return "Error: " + res.error.
// 3. Call handle on one success and one failure and log both.
//
// ✅ Expected output:
//    Got: user#42
//    Error: not found

// your code here

🎉 Lesson Complete

Practice quiz

What is type narrowing?

  • Shrinking a string's length
  • Removing a type alias
  • Refining a broad type to a more specific one inside a block based on a runtime check
  • Deleting union members permanently

Answer: Refining a broad type to a more specific one inside a block based on a runtime check. After a check like typeof x === 'string', TypeScript treats x as string in that branch.

Which narrowing tool is best for primitive types?

  • typeof
  • instanceof
  • the 'in' operator
  • Array.isArray

Answer: typeof. typeof distinguishes 'string', 'number', 'boolean', and other primitives.

What does a truthiness check like 'if (name)' remove from the type?

  • Only null
  • Only undefined
  • Nothing
  • null, undefined, and falsy values like '' and 0

Answer: null, undefined, and falsy values like '' and 0. It rules out all falsy values, including null, undefined, '', and 0.

Which operator narrows class instances?

  • typeof
  • instanceof
  • in
  • is

Answer: instanceof. if (animal instanceof Cat) lets you call Cat-only methods in that branch.

What does the 'in' operator check during narrowing?

  • Whether a property exists on an object
  • Whether a value is in an array
  • Whether a class extends another
  • The type of a primitive

Answer: Whether a property exists on an object. 'radius' in shape distinguishes a circle from a rectangle by a property.

What makes a function a custom type guard?

  • It returns boolean
  • It uses instanceof
  • Its return type is a predicate like 'x is Foo'
  • It is named with 'is'

Answer: Its return type is a predicate like 'x is Foo'. The 'arg is Type' return type tells the compiler to narrow when the guard returns true.

If a guard returns plain 'boolean' instead of 'x is Foo', what happens?

  • It still narrows
  • TypeScript does NOT narrow the variable
  • It errors
  • It narrows to never

Answer: TypeScript does NOT narrow the variable. Without the predicate, the compiler sees a boolean and won't narrow, even with identical runtime logic.

Why use Array.isArray(x) instead of typeof x === 'object' to detect arrays?

  • typeof [] is 'array'
  • Array.isArray is slower
  • typeof cannot run on arrays
  • typeof [] === 'object', which also matches null and plain objects

Answer: typeof [] === 'object', which also matches null and plain objects. Arrays report 'object' under typeof, so Array.isArray is the specific check.

What is the cleanest narrowing for a union sharing a literal 'kind' field?

  • Many instanceof checks
  • A discriminant check - switch on the 'kind' field
  • as assertions
  • A try/catch

Answer: A discriminant check - switch on the 'kind' field. Switching on a shared discriminant field gives the compiler precise per-branch types.

Why is 'if (count)' risky when 0 is a valid value?

  • It throws on 0
  • 0 becomes a string
  • Truthiness skips 0 along with null/undefined
  • It always returns false

Answer: Truthiness skips 0 along with null/undefined. Truthiness drops 0; use 'if (count !== undefined)' when zero is legitimate.

Continue this course

Frequently asked questions

What is type narrowing?

Narrowing is when TypeScript refines a broad type (like string | number) to a more specific one inside a block of code, based on a runtime check you wrote. After if (typeof x === "string"), TypeScript treats x as a string for the rest of that branch, so string methods become safe to call.

When do I need a custom type guard instead of typeof or instanceof?

typeof works for primitives and instanceof works for class instances, but neither can distinguish two plain object shapes. A custom type guard — a function returning the predicate 'arg is SomeType' — lets you encode any check you like (e.g. 'has a swim method') and have TypeScript narrow based on its result.

What does the 'x is Foo' return type actually do?

It is a type predicate. At runtime the function just returns a boolean, but the 'x is Foo' annotation tells the compiler: 'if this returned true, treat the argument as Foo from here on.' Without it, TypeScript would see a plain boolean and would NOT narrow the variable.

Why is the 'in' operator useful for narrowing?

The in operator checks whether a property exists on an object ('radius' in shape). For a union of object shapes that don't share a class, checking for a distinctive property is a clean way to tell them apart, and TypeScript narrows the union accordingly inside the branch.

Related lessons