Learn

/

Type Narrowing

Type Narrowing

6 patterns

Discriminated unions, type guards, typeof, and instanceof for safely accessing properties. You'll hit this when TypeScript complains about a property that might not exist on a union type.

Avoid
function double(value: string | number) {
  // No narrowing, just cast
  return (value as number) * 2;
}

double("hello"); // NaN at runtime
function double(value: string | number) {
  // No narrowing, just cast
  return (value as number) * 2;
}

double("hello"); // NaN at runtime

Prefer
function double(value: string | number) {
  if (typeof value === "number") {
    return value * 2;
  }
  return value.repeat(2);
}

double("hello"); // "hellohello"
function double(value: string | number) {
  if (typeof value === "number") {
    return value * 2;
  }
  return value.repeat(2);
}

double("hello"); // "hellohello"
Why avoid

Casting with as number silences the compiler but does nothing at runtime. If value is a string, multiplying it produces NaN. Type assertions bypass safety checks instead of adding them.

Why prefer

Using typeof to check the type at runtime lets TypeScript narrow the type inside each branch. In the number branch, value is known to be a number, so multiplication is safe. In the string branch, string methods are available. No type assertions needed.

TypeScript Handbook: typeof type guards
Avoid
function greet(name?: string) {
  // Might be undefined
  return `Hello, ${name.toUpperCase()}!`;
}

greet(); // Runtime error
function greet(name?: string) {
  // Might be undefined
  return `Hello, ${name.toUpperCase()}!`;
}

greet(); // Runtime error

Prefer
function greet(name?: string) {
  if (name) {
    return `Hello, ${name.toUpperCase()}!`;
  }
  return "Hello, stranger!";
}

greet(); // "Hello, stranger!"
function greet(name?: string) {
  if (name) {
    return `Hello, ${name.toUpperCase()}!`;
  }
  return "Hello, stranger!";
}

greet(); // "Hello, stranger!"
Why avoid

When name is undefined, calling .toUpperCase() on it throws a TypeError at runtime. TypeScript warns about this with strictNullChecks enabled. Always check optional values before using them.

Why prefer

Checking if (name) filters out both undefined and empty strings. Inside the truthy branch, TypeScript knows name is a non-empty string, so .toUpperCase() is safe. This is the simplest form of narrowing.

TypeScript Handbook: Truthiness narrowing
Avoid
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(shape: Shape) {
  // Unsafe access
  return shape.width * shape.height;
  // Error: Property 'width' does not exist
  // on type '{ kind: "circle"; radius: number }'
}
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(shape: Shape) {
  // Unsafe access
  return shape.width * shape.height;
  // Error: Property 'width' does not exist
  // on type '{ kind: "circle"; radius: number }'
}

Prefer
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rect":
      return shape.width * shape.height;
  }
}
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rect":
      return shape.width * shape.height;
  }
}
Why avoid

Without checking kind, TypeScript sees the full union and only allows access to properties shared by all variants. width only exists on the rect variant, so accessing it directly is a compile error.

Why prefer

Discriminated unions use a shared literal property (here kind) to tell variants apart. When you switch on shape.kind, TypeScript narrows each case to the correct variant. You get full autocompletion and type safety in every branch.

TypeScript Handbook: Discriminated unions
Avoid
type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  // Guessing without checking
  (animal as Fish).swim();
}

const bird: Bird = { fly: () => {} };
move(bird); // Runtime error: swim is not a function
type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  // Guessing without checking
  (animal as Fish).swim();
}

const bird: Bird = { fly: () => {} };
move(bird); // Runtime error: swim is not a function

Prefer
type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    animal.swim();
  } else {
    animal.fly();
  }
}
type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    animal.swim();
  } else {
    animal.fly();
  }
}
Why avoid

Using as Fish tells the compiler to trust you, but at runtime there is no check. If the actual value is a Bird, calling .swim() throws because that method does not exist. Type assertions should be a last resort, not a substitute for runtime checks.

Why prefer

The in operator checks whether a property exists on an object at runtime. TypeScript uses this to narrow the type: inside the "swim" in animal branch, animal is narrowed to Fish. This works well when union members have distinct properties but no shared discriminant.

TypeScript Handbook: in operator narrowing
Avoid
function handleError(err: Error | string) {
  // Assumes it's always an Error
  console.log(err.message);
  console.log(err.stack);
  // Error if err is a string
}
function handleError(err: Error | string) {
  // Assumes it's always an Error
  console.log(err.message);
  console.log(err.stack);
  // Error if err is a string
}

Prefer
function handleError(err: Error | string) {
  if (err instanceof Error) {
    console.log(err.message);
    console.log(err.stack);
  } else {
    console.log(err);
  }
}
function handleError(err: Error | string) {
  if (err instanceof Error) {
    console.log(err.message);
    console.log(err.stack);
  } else {
    console.log(err);
  }
}
Why avoid

Accessing .message on a string value fails because strings do not have a .message property. In JavaScript, both throw new Error("oops") and throw "oops" are valid, so catch blocks often receive Error | string (or unknown).

Why prefer

instanceof checks the prototype chain at runtime. Inside the instanceof Error branch, TypeScript knows err is an Error with .message and .stack. In the else branch, it is narrowed to string. This is especially useful for class hierarchies.

TypeScript Handbook: instanceof narrowing
Avoid
type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };

function isCat(pet: Cat | Dog) {
  return "meow" in pet;
}

function handle(pet: Cat | Dog) {
  if (isCat(pet)) {
    // TypeScript still sees Cat | Dog here
    pet.purr(); // Error
  }
}
type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };

function isCat(pet: Cat | Dog) {
  return "meow" in pet;
}

function handle(pet: Cat | Dog) {
  if (isCat(pet)) {
    // TypeScript still sees Cat | Dog here
    pet.purr(); // Error
  }
}

Prefer
type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };

function isCat(pet: Cat | Dog): pet is Cat {
  return "meow" in pet;
}

function handle(pet: Cat | Dog) {
  if (isCat(pet)) {
    // TypeScript knows pet is Cat
    pet.purr(); // OK
  }
}
type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };

function isCat(pet: Cat | Dog): pet is Cat {
  return "meow" in pet;
}

function handle(pet: Cat | Dog) {
  if (isCat(pet)) {
    // TypeScript knows pet is Cat
    pet.purr(); // OK
  }
}
Why avoid

A regular boolean return type tells TypeScript nothing about the argument's type. Even though the function checks "meow" in pet, TypeScript does not propagate that narrowing back to the caller. The type stays Cat | Dog in the if-block.

Why prefer

A user-defined type guard uses the pet is Cat return type to tell TypeScript that when the function returns true, the argument is a specific type. Without this annotation, TypeScript cannot infer the narrowing across function boundaries. The return type pet is Cat bridges runtime logic and compile-time types.

TypeScript Handbook: Type predicates