Type Narrowing and Guards in TypeScript

· 5 min read · Updated March 7, 2026 · beginner
typescript type-narrowing type-guards advanced-types

Type narrowing is the process of refining types from broader to more specific. When you have a union type, TypeScript doesn’t know which exact type you’re working with at runtime. Type narrowing gives you the tools to help TypeScript understand your code and catch more bugs at compile time.

Understanding Type Narrowing

TypeScript starts with a wider type and narrows it down based on conditions you check. The most common narrowing happens with the typeof operator:

function printValue(value: string | number) {
  if (typeof value === "string") {
    // TypeScript knows value is a string here
    console.log(value.toUpperCase());
  } else {
    // TypeScript knows value is a number here
    console.log(value.toFixed(2));
  }
}

This is simple but powerful. TypeScript sees the typeof check and narrows the type inside each branch.

The instanceof Operator

When working with classes, instanceof works similarly to typeof:

class Dog {
  bark() {
    console.log("Woof!");
  }
}

class Cat {
  meow() {
    console.log("Meow!");
  }
}

function makeSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark();
  } else {
    animal.meow();
  }
}

Custom Type Guards

Sometimes built-in checks aren’t enough. You can create custom type guards by returning a type predicate:

interface Fish {
  swim(): void;
}

interface Bird {
  fly(): void;
}

function isFish(animal: Fish | Bird): animal is Fish {
  return (animal as Fish).swim !== undefined;
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim();
  } else {
    animal.fly();
  }
}

The animal is Fish return type tells TypeScript that if the function returns true, the variable is narrowed to Fish.

The in Operator

The in operator checks if a property exists on an object:

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  side: number;
}

type Shape = Circle | Square;

function getArea(shape: Shape) {
  if ("radius" in shape) {
    return Math.PI * shape.radius ** 2;
  }
  return shape.side ** 2;
}

Discriminated Unions

A common pattern is adding a discriminator property to each variant:

type Result =
  | { status: "success"; data: string }
  | { status: "error"; message: string };

function handleResult(result: Result) {
  switch (result.status) {
    case "success":
      console.log(result.data);
      break;
    case "error":
      console.error(result.message);
      break;
  }
}

The status property lets TypeScript narrow the type precisely in each branch.

Truthiness Narrowing

TypeScript also narrows based on truthiness:

function printLength(str: string | null) {
  if (str) {
    console.log(str.length);
  } else {
    console.log("No string provided");
  }
}

Using Type Assertions

When you know more than TypeScript can infer, use non-null assertions or type assertions:

function getFirstElement(arr: number[]): number {
  return arr[0]; // TypeScript sees number | undefined
}

function getFirstElementSafe(arr: number[]): number {
  return arr[0]!; // Assert it's not undefined
}

Be careful with assertions—they bypass TypeScript’s safety net.

Practice Example

Here’s a real-world example combining these techniques:

type ApiResponse =
  | { type: "user"; name: string; email: string }
  | { type: "post"; title: string; content: string }
  | { type: "error"; code: number; message: string };

function processResponse(response: ApiResponse) {
  if (response.type === "user") {
    return `User: ${response.name} (${response.email})`;
  }
  
  if (response.type === "post") {
    return `Post: ${response.title}`;
  }
  
  return `Error ${response.code}: ${response.message}`;
}

Summary

Type narrowing helps TypeScript understand your runtime logic:

  • Use typeof for primitives
  • Use instanceof for class instances
  • Create custom type guards with animal is Type predicates
  • Use in for property checking
  • Prefer discriminated unions when possible
  • Be careful with assertions—they bypass safety checks

Mastering type narrowing makes your TypeScript code both safer and more express

When Narrowing Fails

Sometimes TypeScript cannot narrow automatically. This happens when the runtime check doesn’t provide enough information for TypeScript to infer the type. In these cases, you need to be explicit.

Type Predicates vs Boolean Returns

A common mistake is returning a boolean instead of a type predicate:

// ❌ This doesn't narrow - returns boolean
function isString(value: unknown): boolean {
  return typeof value === "string";
}

// ✅ This narrows - returns type predicate
function isString(value: unknown): value is string {
  return typeof value === "string";
}

When you use the first version, TypeScript has no idea what happened inside the function. With the second version, TypeScript narrows the type after the check.

Exhaustiveness Checking

TypeScript can help you ensure you’ve handled all cases in a union type. Use a function that never returns:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // If TypeScript sees this, you missed a case
      const _exhaustive: never = shape;
      throw new Error(`Unknown shape: ${_exhaustive}`);
  }
}

This pattern ensures that when you add a new shape type, TypeScript will flag the switch statement as incomplete.

Best Practices

  1. Prefer discriminated unions - Add a common property like type or kind to each variant
  2. Use type predicates for complex checks - When simple checks aren’t enough
  3. Enable strict null checks - This makes narrowing more important and useful
  4. Avoid excessive use of type assertions - They defeat the purpose of type safety
  5. Document your type guards - Explain what each guard checks and what it guarantees

Type narrowing is one of TypeScript’s most powerful features. It lets you write safe code while keeping the flexibility of JavaScript’s dynamic nature.