LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 1116 min read

Type Narrowing & Type Guards

Learn how TypeScript narrows union types using typeof, instanceof, the in operator, custom type guards, and exhaustiveness checking with never.

Introduction

Narrowing is how TypeScript takes a broad type — usually a union — and, based on the runtime checks in your code, figures out a more specific type within a particular branch. It is the mechanism that makes union types and discriminated unions practical to work with.

What You Will Learn
  • What narrowing means and why TypeScript needs it for union types.
  • How typeof, truthiness, equality, in, and instanceof checks each narrow a type.
  • How to write a custom type guard function using a type predicate.
  • How discriminated unions narrow cleanly inside a switch statement.
  • How to use never for compile-time exhaustiveness checking.

What is Narrowing?

When a variable has a union type like `string | number`, TypeScript won't let you call a string-only method on it directly — the value might be a number. Narrowing is the process of proving, through an actual runtime check, which specific member of the union you're dealing with in a given branch of code, after which TypeScript allows the more specific operations.

typeof Guards

function printLength(value: string | number) {
if (typeof value === "string") {
console.log(value.length); // narrowed to string
} else {
console.log(value.toFixed(0)); // narrowed to number
}
}

Truthiness and Equality Narrowing

A plain `if (value)` check narrows out `null`, `undefined`, `0`, `""`, and other falsy values. Direct comparisons like `if (value !== null)` or `if (status === "active")` narrow just as effectively, and are often clearer about exactly what's being excluded.

The in Operator

The `in` operator checks whether a given property exists on an object, which TypeScript can use to narrow between union members that have different property names.

type Cat = { meow: () => void };
type Dog = { bark: () => void };
function makeSound(pet: Cat | Dog) {
if ("meow" in pet) {
pet.meow(); // narrowed to Cat
} else {
pet.bark(); // narrowed to Dog
}
}

instanceof Guards

For class instances, `instanceof` narrows a value to a specific class, checking the prototype chain at runtime just like plain JavaScript.

class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
function handle(error: Error) {
if (error instanceof ApiError) {
console.log(`API error ${error.statusCode}: ${error.message}`);
} else {
console.log(`Unknown error: ${error.message}`);
}
}

Custom Type Guards

A function can define its own narrowing rule by returning a type predicate — a return type written as `parameterName is SomeType`. Anywhere this function is called in an `if`, TypeScript trusts its result completely and narrows accordingly.

interface Fish { swim: () => void }
interface Bird { fly: () => void }
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird) {
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
}

Exhaustiveness Checking with never

Combining a discriminated union with a switch statement and a `never`-typed default case gives you a compile-time safety net: if someone adds a new variant to the union later and forgets to handle it in the switch, TypeScript raises an error immediately, since the unhandled case can no longer be assigned to `never`.

type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
const _exhaustive: never = shape; // errors if a case is missing
return _exhaustive;
}
}

Common Mistakes

Avoid These Mistakes
  • Writing a custom type guard whose body doesn't actually verify what its type predicate claims — TypeScript trusts it completely, so a wrong guard silently defeats type safety.
  • Narrowing a variable, then reading it inside a callback where TypeScript can no longer guarantee it hasn't changed, and "forgetting" the narrowing.
  • Reaching for an `as` type assertion instead of a real narrowing check — assertions bypass the checker entirely rather than proving anything.
  • Forgetting that `typeof null === "object"` in JavaScript, which can trip up a naive `typeof value === "object"` null check.

Best Practices

  • Prefer real narrowing (typeof, in, instanceof, custom guards) over `as` type assertions, which suppress errors instead of proving safety.
  • Use discriminated unions with exhaustive switch statements for anything with a fixed number of variants.
  • Add a `never` check in the default case of a switch on a discriminated union, so new variants cause a compile error if left unhandled.
  • Keep custom type guard functions small and obviously correct — TypeScript trusts their result unconditionally.

Frequently Asked Questions

Narrowing is TypeScript proving a variable's type from actual runtime checks you wrote; an assertion just tells the compiler to trust you, with no runtime verification, and can be unsafe if it's wrong.

The `parameterName is SomeType` return type syntax on a function, which tells TypeScript to treat any call to that function as a narrowing check wherever it is used.

Because assigning anything to a variable typed never fails to compile unless every case is truly impossible — if a new union variant is added and left unhandled, this catches it immediately.

No — narrowing is a compile-time-only analysis. The emitted JavaScript is exactly the runtime checks you wrote (typeof, in, instanceof); TypeScript adds nothing extra.

Key Takeaways

  • Narrowing lets TypeScript prove a more specific type within a branch of code, based on a runtime check.
  • typeof, truthiness/equality checks, in, and instanceof are all built-in narrowing tools.
  • A custom type guard uses a type predicate (`x is T`) to teach TypeScript a new narrowing rule.
  • Discriminated unions narrow cleanly inside a switch statement on their shared literal property.
  • A never-typed default case gives compile-time exhaustiveness checking for discriminated unions.

Summary

You can now safely work with union types using real narrowing instead of unsafe type assertions. For the final lesson, you'll pull everything together with a look at TypeScript best practices, project setup, and what to learn next.

Next Lesson →

Best Practices & Next Steps