johnieskills

Fixes TypeScript errors and strengthens your types.

Resolve TypeScript errors, eliminate any, and design complex types (generics, conditional, mapped, template literal, branded/opaque). Use for type-inference problems, infer / extends questions, utility types (Partial, Record, ReturnType, Awaited, NoInfer), satisfies, function overloads, declaration merging, and strict-mode refactors.

$ npx skills add johnie/skills --skill typescript-engineer
skills/typescript-engineer/references/type-narrowing.mdGitHub

Type Narrowing

Contents

Overview

Type narrowing is TypeScript's ability to refine types based on control flow analysis. When you check a type condition, TypeScript narrows the type within that code block.

Built-in Narrowing

typeof Guards

function processValue(value: string | number) {
  if (typeof value === "string") {
    // value is string here
    return value.toUpperCase();
  }
  // value is number here
  return value.toFixed(2);
}

instanceof Guards

function logError(error: Error | string) {
  if (error instanceof Error) {
    // error is Error here
    console.log(error.stack);
  } else {
    // error is string here
    console.log(error);
  }
}

Truthiness Narrowing

function printName(name: string | null | undefined) {
  if (name) {
    // name is string here (truthy)
    console.log(name.toUpperCase());
  }
}

Equality Narrowing

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // Both are string here (only common type)
    console.log(x.toUpperCase());
    console.log(y.toUpperCase());
  }
}

in Operator

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

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

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    // animal is Fish here
    animal.swim();
  } else {
    // animal is Bird here
    animal.fly();
  }
}

Discriminated Unions

Use a common property to discriminate between types:

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

interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

type Shape = Circle | Rectangle | Triangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // shape is Circle here
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      // shape is Rectangle here
      return shape.width * shape.height;
    case "triangle":
      // shape is Triangle here
      return (shape.base * shape.height) / 2;
  }
}

Exhaustiveness Checking

Use never to ensure all cases are handled:

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // If a new shape is added, this will error
      const _exhaustiveCheck: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustiveCheck}`);
  }
}

Custom Type Guards

Inferred Type Predicates (TS 5.5+)

Since TypeScript 5.5 you usually should not write value is Type by hand. When a function has no explicit return type and returns a boolean expression that narrows one of its parameters, the compiler infers the predicate:

// No annotation needed — TS infers `value is string`
function isString(value: unknown) {
  return typeof value === "string";
}

// The change that matters most in practice: inline callbacks now narrow
const values = [1, null, 2, undefined, 3];
const numbers = values.filter((v) => v !== null && v !== undefined);
// numbers: number[]   (before 5.5: (number | null | undefined)[])

Write an explicit predicate only when inference can't apply. It does not fire when:

  • the function has an explicit return type — annotating : boolean opts out
  • the parameter is reassigned anywhere in the body
  • the narrowing isn't derivable from a single returned expression, e.g. several return statements each narrowing differently
  • you need to assert a different type than control flow proves — most importantly a brand, where nothing in the body establishes it

That last point is why the branded validator further down still needs email is ValidEmail: no amount of includes("@") tells the compiler about a nominal brand.

An explicit predicate is an unchecked assertion — the compiler takes your word for it. When you write one, prove it with a type test (see type-testing.md); a wrong predicate silently lies for the life of the codebase.

Type Predicates

Functions that return value is Type:

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 is Fish here
    pet.swim();
  } else {
    // pet is Bird here
    pet.fly();
  }
}

Generic Type Guards

function isNotNull<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

const values = [1, null, 2, undefined, 3];
const filtered = values.filter(isNotNull);
// filtered is number[]

On TS 5.5+ an inline values.filter((v) => v != null) narrows on its own, so this helper is about naming and reuse across call sites, not about unlocking the narrowing. Keep it if the predicate is shared; drop it if it's used once.

Object Property Check

function hasProperty<T extends object, K extends string>(
  obj: T,
  key: K
): obj is T & Record<K, unknown> {
  return key in obj;
}

const data: unknown = { name: "Alice" };

if (typeof data === "object" && data !== null && hasProperty(data, "name")) {
  // data.name is now accessible
  console.log(data.name);
}

Narrowing with Opaque Types

Combine type predicates with opaque types for validated data:

type ValidEmail = string & { __brand: "ValidEmail" };

function isValidEmail(email: string): email is ValidEmail {
  return email.includes("@") && email.includes(".");
}

function sendEmail(email: ValidEmail) {
  // We know email has been validated
}

function handleSubmit(email: string) {
  if (!isValidEmail(email)) {
    throw new Error("Invalid email");
  }
  // email is ValidEmail here
  sendEmail(email);
}

Array Filtering with Type Guards

type Item = { type: "a"; value: string } | { type: "b"; count: number };

const items: Item[] = [
  { type: "a", value: "hello" },
  { type: "b", count: 42 },
];

// Filter to specific type
const typeAItems = items.filter(
  (item): item is { type: "a"; value: string } => item.type === "a"
);
// typeAItems is { type: "a"; value: string }[]

Control Flow Analysis Limitations

TypeScript can't always track narrowing across function calls:

function isString(x: unknown): x is string {
  return typeof x === "string";
}

function example(value: string | number) {
  const isStr = isString(value);

  if (isStr) {
    // value is still string | number here!
    // TypeScript doesn't narrow based on boolean variables
  }

  // Must check inline
  if (isString(value)) {
    // value is string here
  }
}

Practical Example: API Response Handling

interface SuccessResponse<T> {
  status: "success";
  data: T;
}

interface ErrorResponse {
  status: "error";
  error: {
    code: string;
    message: string;
  };
}

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

function isSuccess<T>(
  response: ApiResponse<T>
): response is SuccessResponse<T> {
  return response.status === "success";
}

async function fetchUser(): Promise<ApiResponse<User>> {
  // ...
}

async function handleUser() {
  const response = await fetchUser();

  if (isSuccess(response)) {
    // response.data is User
    console.log(response.data.name);
  } else {
    // response.error is accessible
    console.error(response.error.message);
  }
}

When to Use Each Technique

Technique Use Case
typeof Primitive type checks
instanceof Class instance checks
in operator Property existence checks
Discriminated unions Multiple related types with common discriminant
Type predicates Custom narrowing logic
Assertion functions Validation with early error throwing — see assertion-functions.md

Common Pitfalls

Narrowing Doesn't Persist Across Callbacks

function example(value: string | null) {
  if (value !== null) {
    // value is string here

    setTimeout(() => {
      // value is string | null again!
      // TypeScript is conservative about callbacks
    }, 0);
  }
}

Type Guards Must Return Boolean

// Wrong - doesn't narrow
function isFish(pet: Fish | Bird) {
  return "swim" in pet; // Just returns boolean
}

// Correct - narrows the type
function isFish(pet: Fish | Bird): pet is Fish {
  return "swim" in pet;
}

Be Careful with Complex Conditions

function example(value: { a?: string; b?: number }) {
  // This doesn't narrow as expected
  if (value.a || value.b) {
    // Neither a nor b is guaranteed to exist
  }

  // Use specific checks
  if (value.a !== undefined) {
    // value.a is string here
  }
}

See also