Union, Intersection and Narrowing in TypeScript

1. Overview

Union and intersection are two basic tools for TypeScript to describe data more flexibly than plain interface. Narrowing is a technique that helps the compiler “understand” the specific type of a value at each branch of code, rather than forcing you to manually cast (as) the type.

This article goes from the concept of union/intersection, through common narrowing techniques (typeof, instanceof, in, discriminated union), to writing custom type guards. After reading, you’ll know when to use union, when to use intersection, and avoid errors like Object is possibly undefined or widespread as any in your code.

2. Union type

Union (|) means a value can belong to one of many types.

type PaymentMethod = "cash" | "card" | "e-wallet";

function describe(method: PaymentMethod): string {
  return `Pay with: ${method}`;
}

describe("card"); // OK
// describe("paypal"); // Compile error: not in union

PaymentMethod only accepts 3 specific string literals. If you pass a different value, TypeScript reports an error at compile time, not at runtime.

Union also works with object types:

interface CashPayment {
  type: "cash";
  amount: number;
}

interface CardPayment {
  type: "card";
  amount: number;
  cardNumber: string;
}

type Payment = CashPayment | CardPayment;

Now, a variable of type Payment can be either CashPayment or CardPayment, but TypeScript only lets you access the common fields of both (here type and amount) if you haven’t done narrowing yet.

function printAmount(p: Payment) {
  console.log(p.amount); // OK, common field
  // console.log(p.cardNumber); // Error: doesn't exist on CashPayment
}

Note: Union doesn’t merge fields together. To access private fields, you must narrow first (see section 4).

3. Intersection type

Intersection (&) means a value must satisfy all the combined types simultaneously.

interface Identifiable {
  id: string;
}

interface Timestamped {
  createdAt: Date;
}

type Order = Identifiable & Timestamped & {
  customerName: string;
  total: number;
};

const order: Order = {
  id: "ORD-001",
  createdAt: new Date(),
  customerName: "John Doe",
  total: 150_000,
};

Order here has all fields from Identifiable, Timestamped and the additional object literal. Missing any field, the compiler will report an error.

Intersection is useful when you want to “mix” multiple small mixins into one large type, similar to composition in OOP but at the type level.

type AuditInfo = { updatedBy: string };

type AuditedOrder = Order & AuditInfo;

const audited: AuditedOrder = {
  ...order,
  updatedBy: "admin",
};

3.1. Intersection with conflicting primitive types

If two types in an intersection conflict on primitive types, the result is never:

type Impossible = string & number; // never type, no value satisfies it

This case is usually a sign you typed & instead of | by mistake.

4. Narrowing: narrowing types within each code branch

Narrowing is the process where the compiler infers a more specific type based on conditional checks in your code (if, switch, typeof…).

4.1. Narrowing with typeof

Use for unions of primitive types.

function formatId(id: string | number): string {
  if (typeof id === "number") {
    return id.toFixed(0); // id is narrowed to number
  }
  return id.toUpperCase(); // id is narrowed to string
}

In the if branch, TypeScript knows id is number so it lets you call .toFixed(). In the else branch (implicit), id is only string.

4.2. Narrowing with instanceof

Use for unions of classes.

class ValidationError extends Error {
  constructor(public field: string) {
    super(`Field ${field} is invalid`);
  }
}

function handleError(err: Error | ValidationError): void {
  if (err instanceof ValidationError) {
    console.log(`Validation error on field: ${err.field}`);
  } else {
    console.log(`Other error: ${err.message}`);
  }
}

err.field only exists on ValidationError, so the compiler only lets you access it in the branch where you’ve checked instanceof.

4.3. Narrowing with in

Use when types in a union are distinguished by private fields (no common discriminant field).

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

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

function makeSound(pet: Dog | Cat): void {
  if ("bark" in pet) {
    pet.bark(); // narrow to Dog
  } else {
    pet.meow(); // narrow to Cat
  }
}

The in operator checks if a property exists on an object at runtime, and the compiler uses that result to narrow the type accordingly.

4.4. Discriminated union — the safest approach

This is the most common pattern in real-world code: add a common literal field (usually called kind or type) to distinguish “branches” in a union.

interface SuccessResult {
  status: "success";
  data: string;
}

interface ErrorResult {
  status: "error";
  message: string;
}

type ApiResult = SuccessResult | ErrorResult;

function handleResult(result: ApiResult): void {
  switch (result.status) {
    case "success":
      console.log(`Data: ${result.data}`);
      break;
    case "error":
      console.log(`Error: ${result.message}`);
      break;
  }
}

The compiler uses the status field as a “discriminant”: in case "success", result is narrowed to SuccessResult; in case "error", narrowed to ErrorResult. This is how TypeScript recommends all unions with more than 2 branches, because switch also supports exhaustiveness checking (section 4.6).

4.5. Narrowing with truthiness and optional chaining

interface Customer {
  name: string;
  phone?: string;
}

function callCustomer(customer: Customer): void {
  if (customer.phone) {
    // customer.phone is narrowed from string | undefined to string
    console.log(`Call: ${customer.phone}`);
  } else {
    console.log("Customer has no phone number");
  }
}

Checking truthy (if (customer.phone)) removes undefined (and empty string, though not relevant for phone numbers here).

4.6. Exhaustiveness check with never

When you add a new branch to a union but forget to handle it, you want the compiler to report an error immediately instead of a silent bug at runtime. Use a never type variable in the default branch:

function assertUnreachable(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

function handleResultSafe(result: ApiResult): string {
  switch (result.status) {
    case "success":
      return result.data;
    case "error":
      return result.message;
    default:
      return assertUnreachable(result); // if a new branch is added and you forget to handle it, compile error here
  }
}

If someone later adds PendingResult to ApiResult but forgets to add the corresponding case, result in the default branch will no longer be never, and the compiler reports an error at build time — no need to wait for the bug to show up in production.

5. User-defined type guard

When the checking logic is more complex than a simple expression, you can write a function that returns x is T type to reuse narrowing.

interface PaidOrder {
  status: "paid";
  paidAt: Date;
}

interface PendingOrder {
  status: "pending";
}

type OrderState = PaidOrder | PendingOrder;

function isPaid(order: OrderState): order is PaidOrder {
  return order.status === "paid";
}

function printReceipt(order: OrderState): void {
  if (isPaid(order)) {
    console.log(`Paid at: ${order.paidAt.toISOString()}`);
  } else {
    console.log("Not paid");
  }
}

order is PaidOrder is a type predicate. The compiler trusts the result of the isPaid function to narrow order to PaidOrder in the if branch, even when the checking logic lives in a separate function.

6. Verification through testing

Narrowing is a compile-time feature so “testing” it really means running tsc without errors. But you should still write tests for the corresponding runtime behavior.

npm install -D vitest typescript
import { describe, it, expect } from "vitest";

function totalAmount(payment: CashPayment | CardPayment): number {
  return payment.amount;
}

describe("Payment narrowing", () => {
  it("calculates correct amount for cash payment", () => {
    const cash: CashPayment = { type: "cash", amount: 50_000 };
    expect(totalAmount(cash)).toBe(50_000);
  });

  it("calculates correct amount for card payment", () => {
    const card: CardPayment = {
      type: "card",
      amount: 200_000,
      cardNumber: "4111111111111111",
    };
    expect(totalAmount(card)).toBe(200_000);
  });
});

Run npx vitest run for output:

✓ Payment narrowing > calculates correct amount for cash payment
✓ Payment narrowing > calculates correct amount for card payment

Test Files  1 passed (1)
     Tests  2 passed (2)

7. Common mistakes and when not to use

  • Abusing as to avoid narrowing: if you find yourself repeatedly casting (as SomeType) to “appease” the compiler, there’s a good chance your union/type guard design is wrong. Go back to using discriminated unions.
  • Intersection of two interfaces with same field name but different types: TypeScript will try to merge, but if types conflict (e.g., one is string, one is number), that field becomes never — very easy to cause confusing errors. Always keep field names consistent when designing mixins.
  • Union too broad (e.g., string | number | boolean | object): everywhere using the type must narrow, making code messy. If a union has more than 4–5 unrelated branches, reconsider your domain model — maybe you should split into multiple functions/overloads.
  • Forgetting default with never in switch: lose the exhaustiveness check benefit, easy to skip a branch when expanding the union.
  • in operator on objects with complex prototype chains: in checks inherited properties too, may narrow in unexpected ways with classes with deep inheritance. Use instanceof or discriminant field instead, more reliable.

Note: Narrowing only works well when TypeScript can statically prove it. If you assign a narrowed value to another variable through a callback function (async operations like setTimeout, promise .then), the narrowed type might be “forgotten” — TypeScript doesn’t track across closure boundaries absolutely in all cases.

8. Conclusion

Union describes “one of many types”, intersection describes “all types at once” — these two tools are the foundation for building accurate domain models in TypeScript. Narrowing (typeof, instanceof, in, discriminated union, custom type guard) lets you leverage unions safely without manual casting.

Practical rules: prefer discriminated unions from 2 branches onward, always add exhaustiveness check with never in switch, and avoid intersection between types with same field name but different types. Applied correctly, you’ll catch a lot of logic errors right while writing code, before it gets a chance to run in production.

typescripttype-safetyjavascriptfrontend