Union Types and Type Narrowing

In the TypeScript type system, Union Types and Type Narrowing function as two sides of the same coin. While Union Types allow a value to represent one of several possible types, Type Narrowing provides the mechanism to refine those possibilities into a specific, concrete type within a particular block of code. Mastering this interplay is essential for writing robust, type-safe applications that leverage the full power of the compiler.
A Union Type allows a variable to hold more than one type of value. By using the pipe operator (|), you can declare that a value can be "this OR that."

let id: string | number;

id = "A-1024"; // Valid
id = 1024;     // Valid
id = true;     // Error: Type 'boolean' is not assignable to type 'string | number'

Union types are ubiquitous in modern TypeScript development. You will frequently encounter them in:

  • Function Parameters: When a function needs to handle different input formats (e.g., a single ID or an array of IDs).
  • API Responses: When a return value might be a successful data object or an error object.
  • State Management: When a status can only be one of a few specific string literals, such as "idle" | "loading" | "success" | "error".

The Access Limitation

The primary challenge with Union Types is that the TypeScript compiler is inherently cautious. When you are working with a union, you can only safely access properties or methods that are common to all members of that union.

function format(value: string | number) {
  // Error: Property 'toUpperCase' does not exist on type 'number'.
  // The compiler cannot guarantee that 'value' is a string at runtime.
  console.log(value.toUpperCase()); 
}

Because the compiler cannot predict which type will be present at runtime, it prevents you from accessing type-specific members. To bridge this gap, you must use Type Narrowing.

Essential Type Narrowing Techniques

Type narrowing is the process of using logic to prove to the compiler that a value belongs to a specific subtype.

1. Using typeof for Primitives

For primitive types like string, number, and boolean, the typeof operator is the most straightforward approach.

function formatValue(value: string | number): string {
  if (typeof value === "string") {
    // Within this block, 'value' is narrowed to 'string'
    return value.toUpperCase();
  }
  // Outside the block, 'value' is narrowed to 'number'
  return value.toFixed(2);
}

Note: Be mindful that typeof null returns "object", which can lead to unexpected behavior if you aren't explicitly checking for nullability.

2. Using instanceof for Objects and Classes

When dealing with class instances or built-in objects like Date, the instanceof operator is the preferred tool.

function logDateOrString(value: Date | string) {
  if (value instanceof Date) {
    // 'value' is narrowed to 'Date'
    console.log(value.toISOString());
  } else {
    // 'value' is narrowed to 'string'
    console.log(value.trim());
  }
}

3. The in Operator for Property Checking

If you are working with object types, you can check for the existence of a specific property using the in operator. This is particularly useful for distinguishing between different interfaces.

interface Bird { fly(): void; }
interface Fish { swim(): void; }

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

4. Literal Equality

For unions composed of string or number literals, simple equality checks (===) are sufficient to narrow the type.

type Status = "idle" | "loading" | "success" | "error";

function renderStatus(status: Status) {
  if (status === "loading") {
    // 'status' is narrowed to the literal type "loading"
    console.log("Loading...");
  }
}

Advanced Pattern: Discriminated Unions

In complex application logic, the most powerful pattern is the Discriminated Union (also known as Tagged Unions). By adding a common literal property (a "tag" or "kind") to every member of the union, you can use a switch statement to create highly predictable and readable narrowing logic.

interface Circle { kind: "circle"; radius: number; }
interface Rect   { kind: "rect"; width: number; height: number; }

type Shape = Circle | Rect;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // shape is narrowed to Circle
      return Math.PI * shape.radius ** 2;
    case "rect":
      // shape is narrowed to Rect
      return shape.width * shape.height;
  }
}

Exhaustiveness Checking with never

To make Discriminated Unions even safer, you can implement exhaustiveness checking. By using the never type in a default case, you ensure that the compiler will throw an error if you ever add a new member to the union without updating your logic.

function assertNever(x: never): never {
  throw new Error("Unexpected object: " + JSON.stringify(x));
}

function getAreaSafe(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "rect":   return shape.width * shape.height;
    default:
      // If a new shape (e.g., Triangle) is added to the Shape union,
      // TypeScript will flag an error here because 'shape' won't be 'never'.
      return assertNever(shape);
  }
}

Custom Type Guards

Sometimes, built-in operators are insufficient for complex validation logic. In these cases, you can define a User-Defined Type Guard using the parameter is Type syntax.

function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every(item => typeof item === "string");
}

const input: unknown = ["apple", "banana"];

if (isStringArray(input)) {
  // 'input' is now safely treated as string[]
  input.map(s => s.toUpperCase());
}

Handling Null and Undefined

In production-grade TypeScript, especially when strictNullChecks is enabled, handling null and undefined is a critical part of narrowing. Beyond explicit equality checks, truthiness is a common way to narrow out nullish values.

function greet(name: string | null) {
  if (name) {
    // 'name' is narrowed to 'string' because null is falsy
    console.log(`Hello, ${name.toUpperCase()}!`);
  } else {
    console.log("Hello, stranger!");
  }
}

Summary

  • Union Types define a value that can be one of several types, but restrict access to only the shared members.
  • Type Narrowing uses control flow analysis to refine a union into a specific type.
  • Common tools include typeof, instanceof, the in operator, and literal comparisons.
  • Discriminated Unions combined with the never type provide the most robust way to model complex data structures and ensure exhaustive handling.
  • Custom Type Guards allow you to encapsulate complex validation logic into reusable, type-safe functions.

By mastering these patterns, you move beyond simply "writing types" to truly leveraging TypeScript to build resilient, self-documenting, and error-resistant codebases.