TypeScript Type Narrowing: Control Flow Analysis

Type narrowing is TypeScript's mechanism for refining broad types into more specific ones based on runtime checks. When you work with union types like `string | number` or nullable values like `User...

Key Insights

  • TypeScript’s control flow analysis automatically narrows union types based on runtime checks like typeof, instanceof, and truthiness, eliminating the need for manual type assertions in most cases
  • Discriminated unions with literal type properties enable exhaustive pattern matching through switch statements, catching unhandled cases at compile time
  • Custom type guard functions using the is predicate syntax extend TypeScript’s narrowing capabilities to complex validation logic that the compiler can’t infer automatically

Introduction to Type Narrowing

Type narrowing is TypeScript’s mechanism for refining broad types into more specific ones based on runtime checks. When you work with union types like string | number or nullable values like User | null, TypeScript’s control flow analysis tracks your code’s logic to determine which specific type a value has at any given point.

This matters because it’s the difference between writing type-safe code that feels natural and constantly fighting the compiler with type assertions. Good type narrowing eliminates entire classes of runtime errors while keeping your code readable.

Here’s the fundamental problem and solution:

function processValue(value: string | number) {
  // Error: Property 'toUpperCase' doesn't exist on type 'string | number'
  // return value.toUpperCase();
  
  if (typeof value === "string") {
    // TypeScript knows value is string here
    return value.toUpperCase();
  }
  
  // TypeScript knows value is number here
  return value.toFixed(2);
}

The typeof check doesn’t just affect runtime behavior—TypeScript’s compiler analyzes the control flow and narrows the type automatically within each branch.

Type Guards and Control Flow Basics

TypeScript recognizes several built-in patterns for type narrowing. The compiler analyzes your conditional logic and adjusts types accordingly.

typeof checks work for JavaScript’s primitive types:

function formatValue(value: string | number | boolean) {
  if (typeof value === "string") {
    return value.trim();
  }
  
  if (typeof value === "number") {
    return value.toFixed(2);
  }
  
  // value is boolean here
  return value ? "yes" : "no";
}

instanceof checks narrow to class types:

class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

function handleError(error: Error | ApiError) {
  if (error instanceof ApiError) {
    console.log(`API error ${error.statusCode}: ${error.message}`);
    return;
  }
  
  console.log(`Generic error: ${error.message}`);
}

Truthiness checks narrow nullable types:

function greetUser(name: string | null | undefined) {
  if (name) {
    // name is string here (null and undefined filtered out)
    console.log(`Hello, ${name.toUpperCase()}`);
  } else {
    // name is null | undefined here
    console.log("Hello, guest");
  }
}

The key insight: TypeScript tracks which code paths eliminate which types. After a truthiness check, null and undefined are removed from the union in the truthy branch.

Discriminated Unions and Literal Type Narrowing

Discriminated unions are the most powerful pattern for modeling complex domain logic with type safety. They use a common property with literal types to distinguish between variants.

type Success<T> = {
  status: "success";
  data: T;
};

type Failure = {
  status: "error";
  error: string;
};

type Result<T> = Success<T> | Failure;

function handleResult(result: Result<User>) {
  if (result.status === "success") {
    // TypeScript knows result is Success<User>
    console.log(result.data.name);
  } else {
    // TypeScript knows result is Failure
    console.log(result.error);
  }
}

The status property acts as a discriminant. TypeScript sees the equality check and narrows the entire object type.

Switch statements provide exhaustive checking:

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

function calculateArea(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:
      // Exhaustiveness check: this will error if we add a new shape
      const _exhaustive: never = shape;
      throw new Error("Unhandled shape");
  }
}

The never type assignment in the default case ensures you handle all variants. If you add a new shape type, TypeScript will error because that new type isn’t assignable to never.

Custom Type Guards (User-Defined Type Predicates)

Sometimes TypeScript can’t infer the narrowing you need. Custom type guards bridge this gap using the is predicate syntax.

interface User {
  id: number;
  name: string;
  email: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof (value as User).id === "number" &&
    "name" in value &&
    typeof (value as User).name === "string" &&
    "email" in value &&
    typeof (value as User).email === "string"
  );
}

function processApiResponse(data: unknown) {
  if (isUser(data)) {
    // data is User here
    console.log(data.email);
  } else {
    throw new Error("Invalid user data");
  }
}

The value is User return type tells TypeScript that if the function returns true, the value is definitely a User. This is more powerful than just returning boolean.

Array filtering is where custom type guards shine:

const mixedArray: (string | number | null)[] = ["hello", 42, null, "world", null];

function isString(value: string | number | null): value is string {
  return typeof value === "string";
}

// strings has type string[], not (string | number | null)[]
const strings = mixedArray.filter(isString);

Without the type predicate, filter would return the same union type. With it, TypeScript knows the result array only contains strings.

Advanced Control Flow Patterns

Control flow analysis works through complex boolean logic:

function processInput(value: string | number | null) {
  if (typeof value === "string" && value.length > 0) {
    // value is string (and we know it's non-empty)
    return value.toUpperCase();
  }
  
  if (typeof value === "number" || value === null) {
    // value is number | null
    return value === null ? "N/A" : value.toFixed(2);
  }
  
  // value is string here (empty string case)
  return "empty";
}

Nested narrowing maintains type information:

type ApiResponse = {
  data?: {
    user?: {
      name: string;
    };
  };
};

function getUserName(response: ApiResponse): string {
  if (response.data) {
    if (response.data.user) {
      // Fully narrowed through nested checks
      return response.data.user.name;
    }
  }
  return "Unknown";
}

Assignment narrowing lets you capture narrowed types:

function example(value: string | number) {
  let normalized: string;
  
  if (typeof value === "string") {
    normalized = value;
  } else {
    normalized = value.toString();
  }
  
  // normalized is definitely string here
  return normalized.toUpperCase();
}

Common Pitfalls and Best Practices

Closures break narrowing because TypeScript can’t guarantee the value hasn’t changed:

function problematic(value: string | null) {
  if (value !== null) {
    // value is string here
    
    setTimeout(() => {
      // Error: value might be null here
      // TypeScript can't track mutations across async boundaries
      console.log(value.toUpperCase());
    }, 100);
  }
}

Solution: Capture the narrowed value in a const:

function fixed(value: string | null) {
  if (value !== null) {
    const validValue = value;
    
    setTimeout(() => {
      // validValue is definitely string
      console.log(validValue.toUpperCase());
    }, 100);
  }
}

Mutability issues occur when TypeScript can’t prove a value hasn’t changed:

function checkAndUse(obj: { value: string | number }) {
  if (typeof obj.value === "string") {
    // obj.value might have changed by the time we use it
    // TypeScript doesn't narrow here in all cases
    someFunction();
    // obj.value could be reassigned by someFunction
  }
}

Solution: Extract to a local const:

function checkAndUse(obj: { value: string | number }) {
  const value = obj.value;
  
  if (typeof value === "string") {
    // value is definitely string and can't be mutated
    someFunction();
    console.log(value.toUpperCase());
  }
}

Array methods sometimes lose narrowing:

const items: (string | null)[] = ["a", null, "b"];

// This doesn't narrow the type
const filtered = items.filter(item => item !== null);
// filtered is still (string | null)[]

// Use a type guard instead
const properlyFiltered = items.filter((item): item is string => item !== null);
// properlyFiltered is string[]

Conclusion

TypeScript’s control flow analysis is sophisticated enough to handle most type narrowing automatically through standard JavaScript patterns. Use typeof and instanceof for simple cases, discriminated unions for complex domain modeling, and custom type guards when you need explicit validation logic.

The key is understanding that TypeScript analyzes your code’s control flow—not just individual expressions. Every if statement, switch case, and boolean expression contributes to the compiler’s understanding of what types are possible at each point.

Avoid type assertions (as casts) unless absolutely necessary. If you find yourself using them frequently, you probably need better type guards or discriminated unions. Let TypeScript’s control flow analysis do the heavy lifting—it’s more reliable than manual assertions and makes refactoring safer.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.