TypeScript Type Narrowing

Refining types through code flow analysis

🎯 What is Type Narrowing?

Type Narrowing is TypeScript's ability to automatically refine and reduce broad types to more specific types based on conditional checks, control flow, and logical operations in your code.


// Type narrowing example
function printValue(value: string | number) {
  if (typeof value === 'string') {
    // Type narrowed to string
    console.log(value.toUpperCase());
  }
}
                                    

Output:

✅ Type automatically narrowed from union to specific type

Key Type Narrowing Concepts

Truthiness Narrowing

Narrow based on truthy/falsy values

if (value) {
  // value is truthy
  console.log(value);
}
🔍

Equality Narrowing

Narrow using equality checks

if (x === y) {
  // x and y have same type
}
🎛️

Control Flow Analysis

TypeScript tracks type changes

let x: string | number;
x = 'hello';
// x is now string
🚫

Never Type

Exhaustive checking

default:
  const _exhaustive: never = x;
  return _exhaustive;

🔹 Truthiness Narrowing

TypeScript narrows types based on truthy/falsy checks:

function printLength(str: string | null) {
  // Before check: str is string | null
  
  if (str) {
    // Inside: str is narrowed to string
    console.log(str.length);
  } else {
    // Inside: str is null
    console.log('No string provided');
  }
}

printLength('Hello');  // Output: 5
printLength(null);     // Output: No string provided

// Checking for undefined
function processValue(value: string | undefined) {
  if (value !== undefined) {
    console.log(value.toUpperCase());
  }
}

// Checking for empty arrays
function printArray(arr: number[] | null) {
  if (arr && arr.length > 0) {
    console.log(`First element: ${arr[0]}`);
  }
}

Output:

5

No string provided

🔹 Equality Narrowing

Use equality operators to narrow types:

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

// Checking specific values
function processStatus(status: 'success' | 'error' | 'loading') {
  if (status === 'success') {
    console.log('Operation completed!');
  } else if (status === 'error') {
    console.log('Something went wrong!');
  } else {
    console.log('Please wait...');
  }
}

// Null/undefined checks
function printName(name: string | null | undefined) {
  if (name != null) {
    // Checks both null and undefined
    console.log(name.toUpperCase());
  }
}

🔹 Control Flow Analysis

TypeScript tracks type changes through code flow:

function example() {
  let x: string | number | boolean;
  
  x = Math.random() < 0.5;
  // x is boolean here
  console.log(x);
  
  if (Math.random() < 0.5) {
    x = 'hello';
    // x is string here
    console.log(x.toUpperCase());
  } else {
    x = 100;
    // x is number here
    console.log(x.toFixed(2));
  }
  
  // x is string | number here (boolean eliminated)
  return x;
}

// Assignment narrowing
function processInput(input: string | number) {
  let result: string;
  
  if (typeof input === 'string') {
    result = input;  // input is string
  } else {
    result = input.toString();  // input is number
  }
  
  return result;  // result is always string
}

🔹 Type Predicates

Functions that return type information:

interface Cat {
  meow: () => void;
}

interface Dog {
  bark: () => void;
}

// Type predicate function
function isCat(animal: Cat | Dog): animal is Cat {
  return (animal as Cat).meow !== undefined;
}

function makeSound(animal: Cat | Dog) {
  if (isCat(animal)) {
    // animal is narrowed to Cat
    animal.meow();
  } else {
    // animal is narrowed to Dog
    animal.bark();
  }
}

// Array filtering with type predicates
function isNumber(value: string | number): value is number {
  return typeof value === 'number';
}

const mixed = [1, 'two', 3, 'four', 5];
const numbers = mixed.filter(isNumber);  // number[]

🔹 Discriminated Unions

Use a common property to narrow union types:

interface SuccessResponse {
  status: 'success';
  data: string;
}

interface ErrorResponse {
  status: 'error';
  error: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse) {
  // Narrow based on status property
  if (response.status === 'success') {
    console.log(`Data: ${response.data}`);
  } else {
    console.log(`Error: ${response.error}`);
  }
}

// Switch statement narrowing
function getArea(shape: Circle | Square | Triangle) {
  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;
  }
}

🔹 Exhaustiveness Checking

Ensure all cases are handled using never type:

type Shape = 'circle' | 'square' | 'triangle';

function getShapeArea(shape: Shape): number {
  switch (shape) {
    case 'circle':
      return 3.14;
    case 'square':
      return 4;
    case 'triangle':
      return 2;
    default:
      // If we add a new shape and forget to handle it,
      // TypeScript will show an error here
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

// Practical example
type Status = 'idle' | 'loading' | 'success' | 'error';

function handleStatus(status: Status) {
  switch (status) {
    case 'idle':
      return 'Ready to start';
    case 'loading':
      return 'Loading...';
    case 'success':
      return 'Done!';
    case 'error':
      return 'Failed!';
    default:
      const _exhaustive: never = status;
      throw new Error(`Unhandled status: ${_exhaustive}`);
  }
}

🧠 Test Your Knowledge

What happens when you check if a value is truthy in TypeScript?