You are viewing a preview of this lesson. Sign in to start learning
Back to TypeScript

Type Narrowing

Refine variable types based on runtime conditions using typeof, instanceof, equality, and truthiness checks.

Type Narrowing in TypeScript

TypeScript's type narrowing empowers developers to write safer, more predictable code by refining broad types into specific ones. Master type narrowing with free flashcards and spaced repetition to solidify your understanding of type guards, discriminated unions, control flow analysis, and the never typeβ€”essential concepts for building robust TypeScript applications.

Welcome to Type Narrowing πŸ’»

Welcome to one of TypeScript's most powerful features! Type narrowing is the process by which TypeScript refines a variable's type from a general type to a more specific type within a particular scope. This happens through various techniques that prove to the compiler what type you're actually working with at runtime.

Think of type narrowing like a security checkpoint πŸ”’: you start with a general "person" type, but through various checks (ID verification, credentials), you narrow down to knowing exactly who they areβ€”"employee," "visitor," or "contractor." Similarly, TypeScript starts with broad types and narrows them down through your code's logic.

Core Concepts 🎯

What is Type Narrowing?

Type narrowing is TypeScript's ability to analyze your code's control flow and determine more specific types for variables based on conditional checks, type guards, and other runtime logic. When you perform checks like typeof, instanceof, or custom type guards, TypeScript intelligently updates its understanding of what type a variable can be.

function processValue(value: string | number) {
  // At this point, value is string | number
  
  if (typeof value === "string") {
    // Inside this block, TypeScript knows value is string
    console.log(value.toUpperCase());
  } else {
    // Here, TypeScript knows value must be number
    console.log(value.toFixed(2));
  }
}

Type Guards πŸ›‘οΈ

A type guard is any expression that performs a runtime check and guarantees a variable's type within a specific scope. TypeScript has several built-in type guards:

1. The typeof Type Guard

The typeof operator is perfect for primitive types:

function printValue(value: string | number | boolean) {
  if (typeof value === "string") {
    console.log(`String: ${value.toUpperCase()}`);
  } else if (typeof value === "number") {
    console.log(`Number: ${value.toFixed(2)}`);
  } else {
    console.log(`Boolean: ${value ? "true" : "false"}`);
  }
}

πŸ’‘ Tip: typeof returns strings like "string", "number", "boolean", "object", "function", "undefined", and "symbol".

2. The instanceof Type Guard

The instanceof operator checks if an object is an instance of a specific class:

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

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

function makeSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark(); // TypeScript knows it's a Dog
  } else {
    animal.meow(); // TypeScript knows it's a Cat
  }
}

3. The in Operator Type Guard

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

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

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

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    animal.swim(); // TypeScript knows it's a Fish
  } else {
    animal.fly(); // TypeScript knows it's a Bird
  }
}

⚠️ Common Mistake: The in operator checks for properties at runtime, so be careful with optional properties that might be undefined.

Custom Type Guards (User-Defined Type Guards) πŸ”§

You can create your own type guards using type predicates. A type predicate is a return type in the form parameterName is Type:

interface Car {
  drive: () => void;
  wheels: 4;
}

interface Boat {
  sail: () => void;
  hull: string;
}

// Custom type guard function
function isCar(vehicle: Car | Boat): vehicle is Car {
  return (vehicle as Car).drive !== undefined;
}

function operate(vehicle: Car | Boat) {
  if (isCar(vehicle)) {
    vehicle.drive(); // TypeScript knows it's a Car
  } else {
    vehicle.sail(); // TypeScript knows it's a Boat
  }
}

🧠 Memory Device: Think of "is" in type predicates as "proves this is"β€”the function proves the type.

Discriminated Unions (Tagged Unions) 🏷️

A discriminated union uses a common property (the discriminant) with literal types to distinguish between union members:

interface Circle {
  kind: "circle"; // discriminant property
  radius: number;
}

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

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

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // TypeScript knows shape is Circle here
      return Math.PI * shape.radius ** 2;
    case "square":
      // TypeScript knows shape is Square here
      return shape.sideLength ** 2;
    case "triangle":
      // TypeScript knows shape is Triangle here
      return (shape.base * shape.height) / 2;
  }
}

πŸ’‘ Best Practice: Always use literal types for discriminant properties. This makes exhaustiveness checking work properly.

Control Flow Analysis 🌊

TypeScript performs sophisticated control flow analysis to track how types change through your code:

function processInput(input: string | null | undefined) {
  // input is: string | null | undefined
  
  if (!input) {
    // input is: null | undefined (falsy values)
    console.log("No input provided");
    return;
  }
  
  // After the return, TypeScript knows input must be string
  console.log(input.toUpperCase());
}
CONTROL FLOW NARROWING

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ input: string | null       β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
                 β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ if (input === null)        β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚          β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”  β”Œβ”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
      β”‚ TRUE      β”‚  β”‚ FALSE   β”‚
      β”‚ input:    β”‚  β”‚ input:  β”‚
      β”‚ null      β”‚  β”‚ string  β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Truthiness Narrowing βœ…

TypeScript narrows types based on truthiness checks:

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

⚠️ Watch Out: Empty strings, 0, NaN, and false are also falsy! Use explicit checks when these are valid values:

function processNumber(value: number | null) {
  // ❌ Wrong: 0 is falsy, but it's a valid number!
  if (value) {
    console.log(value * 2);
  }
  
  // βœ… Correct: explicitly check for null
  if (value !== null) {
    console.log(value * 2); // Now 0 works correctly
  }
}

Equality Narrowing πŸ”

Strict equality checks (===, !==) narrow types effectively:

function compare(x: string | number, y: string | boolean) {
  if (x === y) {
    // Both must be string (the only common type)
    console.log(x.toUpperCase());
    console.log(y.toUpperCase());
  }
}

The never Type and Exhaustiveness Checking 🚫

The never type represents values that never occur. It's incredibly useful for exhaustiveness checking:

type Status = "pending" | "approved" | "rejected";

function handleStatus(status: Status) {
  switch (status) {
    case "pending":
      return "Waiting for approval";
    case "approved":
      return "Request approved";
    case "rejected":
      return "Request rejected";
    default:
      // If we add a new status and forget to handle it,
      // this will cause a type error
      const exhaustiveCheck: never = status;
      return exhaustiveCheck;
  }
}

🧠 Memory Device: Think of never as "this should NEVER happen"β€”if it does, you've missed a case.

Detailed Examples πŸ“

Example 1: API Response Handling

Let's see how type narrowing helps handle API responses safely:

interface SuccessResponse {
  status: "success";
  data: {
    userId: number;
    username: string;
  };
}

interface ErrorResponse {
  status: "error";
  errorCode: number;
  message: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse) {
  // Using discriminated union with the 'status' property
  if (response.status === "success") {
    // TypeScript knows response is SuccessResponse
    console.log(`Welcome, ${response.data.username}!`);
    console.log(`Your ID is: ${response.data.userId}`);
  } else {
    // TypeScript knows response is ErrorResponse
    console.error(`Error ${response.errorCode}: ${response.message}`);
  }
}

Why this works: The status property with literal types acts as a discriminant, allowing TypeScript to narrow the union type based on the value.

Example 2: Form Input Validation

Type narrowing shines when validating user input:

type FormValue = string | number | boolean | null;

function validateAndProcess(value: FormValue, fieldName: string) {
  // First, check for null
  if (value === null) {
    throw new Error(`${fieldName} is required`);
  }
  
  // Now value is: string | number | boolean
  
  if (typeof value === "string") {
    // value is string
    if (value.trim().length === 0) {
      throw new Error(`${fieldName} cannot be empty`);
    }
    return value.trim();
  }
  
  if (typeof value === "number") {
    // value is number
    if (isNaN(value) || !isFinite(value)) {
      throw new Error(`${fieldName} must be a valid number`);
    }
    return value;
  }
  
  // value is boolean
  return value ? "yes" : "no";
}

Why this works: Each conditional check narrows the type, and TypeScript tracks the remaining possibilities through control flow analysis.

Example 3: Advanced Type Guards with Generics

Combine type guards with generics for reusable narrowing:

function isArray<T>(value: T | T[]): value is T[] {
  return Array.isArray(value);
}

function processData<T>(data: T | T[]) {
  if (isArray(data)) {
    // data is T[]
    console.log(`Processing ${data.length} items`);
    data.forEach(item => console.log(item));
  } else {
    // data is T
    console.log("Processing single item:", data);
  }
}

processData("hello");
processData([1, 2, 3, 4]);

Why this works: The type predicate value is T[] tells TypeScript that when the function returns true, the value is definitely an array.

Example 4: Nullish Value Handling

Modern TypeScript patterns for handling nullable values:

interface User {
  id: number;
  name: string;
  email?: string; // optional property
}

function sendEmail(user: User) {
  // Using optional chaining and nullish coalescing
  const email = user.email ?? "no-email@example.com";
  
  // Type narrowing with explicit check
  if (user.email !== undefined) {
    // user.email is string (not undefined)
    console.log(`Sending to: ${user.email.toLowerCase()}`);
  } else {
    console.log("User has no email address");
  }
}

Why this works: TypeScript narrows optional properties when you explicitly check for undefined.

Common Mistakes ⚠️

Mistake 1: Using Loose Equality

// ❌ Wrong: == doesn't narrow as effectively
function processValue(value: string | number | null) {
  if (value == null) {
    // This catches both null AND undefined
    return;
  }
  // TypeScript might not narrow correctly
}

// βœ… Correct: Use strict equality
function processValue(value: string | number | null) {
  if (value === null) {
    return;
  }
  // Now value is definitely string | number
}

Mistake 2: Forgetting About Empty Strings and Zero

// ❌ Wrong: Truthiness check excludes valid values
function displayScore(score: number | null) {
  if (score) {
    console.log(`Score: ${score}`);
  }
  // BUG: score of 0 is falsy, so it won't display!
}

// βœ… Correct: Explicit null check
function displayScore(score: number | null) {
  if (score !== null) {
    console.log(`Score: ${score}`);
  }
}

Mistake 3: Mutating Variables After Narrowing

// ❌ Wrong: Reassignment loses narrowing
function process(value: string | number) {
  if (typeof value === "string") {
    value = value.toUpperCase();
  }
  value = 42; // Now value could be anything!
  value.toUpperCase(); // Error: number doesn't have toUpperCase
}

// βœ… Correct: Use const for narrowed values
function process(value: string | number) {
  if (typeof value === "string") {
    const upperValue = value.toUpperCase();
    console.log(upperValue);
  }
}

Mistake 4: Not Using Discriminated Unions

// ❌ Wrong: Hard to narrow
interface Shape {
  radius?: number;
  sideLength?: number;
  width?: number;
  height?: number;
}

// βœ… Correct: Use discriminated unions
interface Circle {
  kind: "circle";
  radius: number;
}

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

type Shape = Circle | Square;

Mistake 5: Ignoring Exhaustiveness Checking

type Status = "pending" | "approved" | "rejected";

// ❌ Wrong: No exhaustiveness check
function handleStatus(status: Status) {
  if (status === "pending") {
    return "Waiting";
  } else if (status === "approved") {
    return "Done";
  }
  // Forgot "rejected" - no error!
}

// βœ… Correct: Use never for exhaustiveness
function handleStatus(status: Status): string {
  switch (status) {
    case "pending":
      return "Waiting";
    case "approved":
      return "Done";
    case "rejected":
      return "Denied";
    default:
      const exhaustive: never = status;
      throw new Error(`Unhandled status: ${exhaustive}`);
  }
}

πŸ€” Did You Know?

TypeScript's control flow analysis is actually a form of abstract interpretationβ€”a static analysis technique from computer science that traces all possible program execution paths. This is the same technology used in advanced compiler optimizations and security analysis tools!

Key Takeaways πŸŽ“

  1. Type narrowing refines general types into specific ones based on runtime checks
  2. Type guards (typeof, instanceof, in) are built-in narrowing mechanisms
  3. Custom type guards use type predicates (param is Type) for complex scenarios
  4. Discriminated unions use a common property with literal types for easy narrowing
  5. Control flow analysis automatically tracks type changes through your code
  6. The never type enables exhaustiveness checking in switch statements
  7. Use strict equality (===) for reliable narrowing
  8. Be careful with truthiness checksβ€”they exclude valid falsy values like 0 and ""

πŸ”§ Try This!

Create a function that accepts unknown (the type-safe counterpart of any) and narrows it safely:

function processUnknown(value: unknown) {
  if (typeof value === "string") {
    console.log(value.toUpperCase());
  } else if (typeof value === "number") {
    console.log(value.toFixed(2));
  } else if (Array.isArray(value)) {
    console.log(`Array with ${value.length} items`);
  } else {
    console.log("Unknown type");
  }
}

This pattern is essential when working with external data sources!

πŸ“š Further Study

πŸ“‹ Quick Reference Card

typeofNarrows primitive types: string, number, boolean, etc.
instanceofNarrows class instances
inChecks property existence for narrowing
Type Predicateparam is Type for custom guards
Discriminated UnionCommon property with literal types
neverFor exhaustiveness checking
TruthinessNarrows based on falsy/truthy, but beware 0 and ""
EqualityUse === for reliable narrowing

Practice Questions

Test your understanding with these questions:

Q1: Write a custom type guard function that checks if a value is an array of numbers. Use the type predicate syntax.
A: !AI
Q2: Implement a function that takes a union type 'string | number | boolean' and returns a formatted string describing the value's type and content. Use type narrowing with typeof to handle each case appropriately.
A: !AI