Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 038 / 100

Unions and narrowing: a practical guide for developers

Model alternatives, then narrow safely.

Expona Admin7 min read

Introduction

When you need to model a value that could be a string, a number, or an object—but not at the same time—you're working with one of TypeScript's most powerful features: union types. Unions and narrowing is a practical guide for developers because it forces you to think clearly about what values are actually possible and then safely extract the type you need. Without proper narrowing, TypeScript (and your team) will struggle to understand which operations are safe at any given point in your code.

Over the next 38 days of learning JavaScript and TypeScript, understanding unions and narrowing will save you from silent bugs and make your code more readable. This guide walks through the mechanics and shows you the patterns we use daily in production systems.

Union types: modeling alternatives

A union type explicitly declares that a value can be one of several types. You express this with the pipe operator (|):

typescript
type Response = string | number | null;
type Status = "pending" | "success" | "error";
type Payload = { type: "user"; id: number } | { type: "admin"; role: string };

Union types are honest. They tell everyone reading the code: "this function might return different shapes depending on the input or context." That honesty is what makes narrowing necessary and valuable.

Consider a real scenario: you're building a payment processing service. An API endpoint might return different response shapes depending on whether the transaction succeeded, failed temporarily, or hit a validation error:

typescript
type PaymentResult = 
  | { status: "success"; transactionId: string; amount: number }
  | { status: "failed"; reason: string; retryable: boolean }
  | { status: "pending"; estimatedTime: number };

function handlePayment(result: PaymentResult) {
  // At this point, TypeScript knows result is one of three shapes.
  // We cannot safely access result.transactionId—only "success" has it.
  // We need to narrow first.
}

Without a union, you'd be tempted to model this as a single object with optional fields, which loses type safety and makes it easy to forget to check whether a field exists before using it.

typeof and instanceof: narrowing primitives and classes

The typeof operator narrows unions that include primitive types. It's the first tool you reach for when distinguishing between strings, numbers, booleans, and symbols:

typescript
function processValue(value: string | number | boolean) {
  if (typeof value === "string") {
    // TypeScript now knows value is a string
    console.log(value.toUpperCase());
  } else if (typeof value === "number") {
    // value is narrowed to number
    console.log(value.toFixed(2));
  } else {
    // value is narrowed to boolean
    console.log(!value);
  }
}

The guard works because TypeScript's control flow analysis tracks the condition and understands that if you've checked typeof value === "string", then value must be a string within that branch.

For class instances, use instanceof:

typescript
class HttpError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

class ValidationError extends Error {
  constructor(public fields: string[], message: string) {
    super(message);
  }
}

function handleError(error: HttpError | ValidationError | Error) {
  if (error instanceof HttpError) {
    // error.status is accessible
    console.log(`HTTP error: ${error.status}`);
  } else if (error instanceof ValidationError) {
    // error.fields is accessible
    console.log(`Invalid fields: ${error.fields.join(", ")}`);
  } else {
    // generic Error
    console.log(error.message);
  }
}

instanceof checks the prototype chain, so it works naturally with inheritance and class hierarchies you'll encounter in frameworks like NestJS or when building domain objects in a trading or e-commerce system.

The in operator: narrowing by property existence

When you have a union of objects with different shapes—especially plain objects without a class wrapper—the in operator checks whether a property exists and narrows accordingly:

typescript
type Dog = { name: string; bark: () => void };
type Cat = { name: string; meow: () => void };

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

This pattern shines when you don't want to introduce a formal discriminant (a shared field that identifies the type). However, we often do introduce one intentionally—it's cleaner:

typescript
type Notification =
  | { type: "email"; recipient: string; subject: string }
  | { type: "sms"; phoneNumber: string; content: string }
  | { type: "webhook"; url: string; payload: object };

function sendNotification(notif: Notification) {
  if (notif.type === "email") {
    // notif is narrowed to the email variant
    console.log(`Emailing ${notif.recipient}`);
  } else if (notif.type === "sms") {
    console.log(`Texting ${notif.phoneNumber}`);
  } else {
    console.log(`Posting to ${notif.url}`);
  }
}

A discriminant like type is more explicit and scales better than checking for property existence. In a CRM, e-commerce, or event-driven system, this pattern becomes the backbone of your type safety.

Control flow analysis: TypeScript tracks narrowing through branches

TypeScript's control flow analysis is remarkably smart. It understands that after a truthiness check, an optional field is no longer optional within that block:

typescript
function processUser(user: { name: string; email?: string }) {
  if (user.email) {
    // email is narrowed to string (no longer string | undefined)
    console.log(user.email.toLowerCase());
  }
  // Back here, email is string | undefined again
}

It also tracks narrowing through early returns, which is a common pattern in guard clauses:

typescript
function validateOrder(order: { customerId?: string; items: any[] }) {
  if (!order.customerId) {
    throw new Error("Customer ID is required");
  }
  // After the guard, TypeScript knows customerId is string
  const customerId = order.customerId;
  // ... safe to use customerId
}

And it understands type predicates—functions that narrow a type for the caller:

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

const items: (string | number)[] = ["hello", 42, "world"];
const strings = items.filter(isString);
// strings is now (string)[] due to the type predicate

This matters in production code. When you're filtering API responses, transforming data in a pipeline, or validating incoming requests, control flow analysis keeps your code safe without forcing you to manually assert types.

Common mistakes

  • Forgetting to narrow before access. Trying to call a method on a union type without first narrowing it will cause a TypeScript error. Don't rely on runtime behavior to do the narrowing for you—be explicit.
  • Using type assertion instead of narrowing. value as string tells TypeScript to trust you, but it doesn't actually check anything. Use typeof, instanceof, or guards instead. Type assertions mask bugs.
  • Relying on implicit narrowing across function boundaries. If you narrow a union inside a function and then pass it to another function, the second function receives the original union type, not the narrowed version. Return the narrowed value explicitly.
  • Mixing discriminants inconsistently. If you define a union with a type field as the discriminant, always check that field. Don't mix in checks for other properties—it confuses readers and makes the code fragile.
  • Over-using optional fields instead of unions. If a value can be one of three different objects with mostly different shapes, a union is clearer than a single object with many optional fields.

Key takeaways

  • Union types model alternatives honestly; narrowing lets you safely work with each alternative.
  • Use typeof for primitives, instanceof for class instances, and discriminant checks (like type === "email") for object unions.
  • The in operator narrows by property existence, but a discriminant field is usually clearer and more maintainable.
  • Control flow analysis is powerful—TypeScript tracks narrowing through if branches, returns, and type predicates automatically.
  • Never use type assertions as a shortcut; always narrow explicitly so that readers (and future you) understand what types are actually valid at each point.
  • In production systems handling payments, events, notifications, or user roles, clear union types and precise narrowing are investments in correctness and team clarity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles