Union and intersection types

Model alternatives with unions, combine constraints with intersections, and keep compositions safe through narrowing, discriminants, and never checks.

level intermediate time 11 min at Standard depth
version TypeScript 6
what

A union type A | B accepts a value satisfying at least one member; an intersection type A & B requires a value to satisfy both.

trap

| doesn’t put every property on one object, and & doesn’t merge objects at runtime. Conflicting properties can also make an intersection impossible to construct normally.

fix

Narrow a union before using member-specific fields, give object unions a stable discriminant, and inspect overlapping properties before forming an intersection.

What it is and why it exists

A union type represents alternatives. A string | number value may be a string or a number, and a caller may supply either. Until the receiver identifies the member, it can perform only operations that are safe for every possible value.

An intersection type represents requirements that must all hold. Identified & Timestamped requires one value to have both identity and time fields. It composes independent capabilities and object fragments without requiring an inheritance relationship between their declarations.

The two operators solve different static-modeling problems: a union describes a choice, while an intersection accumulates constraints. Function inputs, parse results, state machines, and event payloads often use unions; permissions, metadata, and reusable capabilities often use intersections.

TypeScript decides compatibility through structural typing . A value can satisfy several declarations at once when it has the required structure, so a union is an inclusive “or,” not an exclusive choice.

Don’t treat unions and intersections as runtime containers. They are erased during compilation: they create no tag, copy no property, and validate no external data. Type declarations must agree with real object construction and runtime checks.

How it works

For ordinary types, it helps to approximate a type as a set of possible values. A | B contains values belonging to A or B, so it is usually wider; A & B contains only values belonging to both, so it is usually narrower. This model explains assignment direction and never, although special types such as any break the simple set intuition.

An expression of type A can be assigned to A | B because the union accepts more possibilities. The reverse usually fails because an A | B value might satisfy only B. An A & B expression can be assigned separately to A and B because it already meets both requirements.

An object union doesn’t automatically become an object with every field optional. { email: string } | { phone: string } means that at least one structure matches, and one runtime object may match both. Use a discriminated union to preserve relationships between fields instead of making every field optional.

Intersections aren’t limited to objects. string & "ready" narrows to the literal type "ready", while string & number has no ordinary value and becomes never. Object intersections are simply their most common use in a structural type system.

Safe operations on a union

When a variable is string | number, the compiler must account for both members. Both have toString(), so it can be called directly. Only strings have toUpperCase() and only numbers have toFixed(), so those operations require narrowing first.

A “common member” means a property and use that the checker can prove safe, not merely a repeated property name. If two members have functions with the same name but incompatible parameter contracts, knowing that the property exists doesn’t make a call safe.

The following table summarizes both compositions from a consumer’s point of view:

CompositionValue must satisfyInformation available before narrowingCommon uses
`AB`At least one of A, BInformation safe for every remaining member
A & BAll of A, BCombined compatible members from both sidesCapability composition, added metadata

Narrowing and discriminated unions

Narrowing removes union members using runtime facts. typeof, instanceof, equality, in, and custom type predicates supply different evidence. A check must reflect the runtime value honestly; a type assertion supplies no evidence.

An object union works best when every member shares one literal discriminant. Both status: "paid" and status: "failed" exist at runtime and let the compiler narrow the whole object together with its payload. This pattern is a discriminated union .

Once every member has been handled, the remaining value has type never, the bottom type . Passing a default branch’s value to a function that accepts never turns a future unhandled member into a compile error.

This exhaustiveness guarantee covers only a union the compiler already trusts. JSON, message queues, and JavaScript callers can supply arbitrary values, so external data must still be validated from unknown before entering a discriminated union.

Intersections, overlapping properties, and runtime values

An object intersection accumulates constraints on one value. If A requires id: string and B requires createdAt: Date, then A & B requires both fields. The type alias itself doesn’t produce an object containing them.

Compatible overlapping properties narrow further. For example, { mode: string } & { mode: "safe" } has mode: "safe". Incompatible overlapping properties intersect to never, as in { id: string } & { id: number }.

A property conflict doesn’t use object spread order to choose the “last value.” JavaScript object spread does overwrite repeated names, but that is a runtime evaluation rule. An intersection requires the final value to satisfy both static constraints. Confusing the two often leads to an unsafe as A & B.

Inspect overlapping keys and their meanings before composing third-party types. If the domain truly replaces a field, remove the old constraint with Omit before adding the new one. If two fields mean different things, rename them instead of asserting over the conflict.

Examples

The next four programs cover a primitive union, a discriminated union, an object intersection, and a union with shared intersection context. Each was checked in strict mode with TypeScript 6.0.3 and actually executed with local tsx.

Narrowing a union

An identifier can arrive as a numeric database key or an external string. The function identifies the member with typeof before calling methods specific to that member.

identifier.ts
type Identifier = string | number;

function canonicalId(id: Identifier): string {
  if (typeof id === "number") {
    return `customer:${id.toString().padStart(4, "0")}`;
  }

  return `customer:${id.trim().toLowerCase()}`;
}

const identifiers: Identifier[] = [42, "  ALPHA-7  "];

for (const identifier of identifiers) {
  console.log(canonicalId(identifier));
}
customer:0042
customer:alpha-7

The numeric branch can call padStart() because toString() has already produced a string. The string branch needs no assertion: after the previous branch returns, only string remains in the control flow.

This union says that two input representations are accepted; it doesn’t decide whether both forms can identify the same customer. Deduplication, ranges, and empty strings are domain rules that the function or an outer validator must still handle explicitly.

Keeping state and payload correlated with a discriminant

Each payment state carries its own required payload. After the switch checks status, it can access the corresponding field directly, while the default branch proves that the current union is exhausted.

payment-state.ts
type PaymentState =
  | { status: "pending"; attempt: number }
  | { status: "paid"; receipt: string }
  | { status: "failed"; reason: string };

function assertNever(value: never): never {
  throw new Error(`Unhandled payment: ${JSON.stringify(value)}`);
}

function summarize(state: PaymentState): string {
  switch (state.status) {
    case "pending":
      return `pending attempt ${state.attempt}`;
    case "paid":
      return `paid with ${state.receipt}`;
    case "failed":
      return `failed: ${state.reason}`;
    default:
      return assertNever(state);
  }
}

const states: PaymentState[] = [
  { status: "pending", attempt: 2 },
  { status: "paid", receipt: "rcpt-81" },
  { status: "failed", reason: "card expired" },
];

for (const state of states) {
  console.log(summarize(state));
}
pending attempt 2
paid with rcpt-81
failed: card expired

If you add { status: "refunded"; reference: string } without adding a case, state in the default branch is no longer never. TypeScript reports an error at assertNever(state).

Changing the model to { status: "pending" | "paid" | "failed"; attempt?: number; receipt?: string; reason?: string } loses the correlation. That type permits paid without a receipt and permits contradictory fields on the same value.

Combining independent capabilities with an intersection

A subscriber satisfies both the identity and preference contracts. A function receiving Subscriber can safely use fields from both sides because the caller must provide the complete intersection.

subscriber.ts
type HasIdentity = {
  id: string;
  email: string;
};

type HasPreferences = {
  locale: "en" | "zh";
  digest: boolean;
};

type Subscriber = HasIdentity & HasPreferences;

function deliveryLabel(subscriber: Subscriber): string {
  const schedule = subscriber.digest ? "daily" : "off";
  return `${subscriber.id}:${subscriber.locale}:${schedule}`;
}

const subscriber: Subscriber = {
  id: "acct-17",
  email: "[email protected]",
  locale: "zh",
  digest: true,
};

console.log(deliveryLabel(subscriber));
console.log(Object.keys(subscriber).sort().join(","));
acct-17:zh:daily
digest,email,id,locale

The second output line comes from the actual object’s keys, not from the Subscriber type. Removing email from the object literal causes a compile error; removing the type alias doesn’t change any runtime object.

These contracts have no overlapping keys, so the composition is direct. If both sides declare locale in a real project, first confirm that their types and meanings agree, then choose whether to keep the intersection, rename the fields, or reshape the object explicitly.

Adding shared context to every union member

An import result still preserves two payloads through a discriminant, but every member must also carry batchId. The parentheses form the union first, and the outer intersection applies batch context to all its members.

import-result.ts
type ImportResult = (
  | { kind: "accepted"; records: number }
  | { kind: "rejected"; errors: string[] }
) & { batchId: string };

function report(result: ImportResult): string {
  if (result.kind === "accepted") {
    return `${result.batchId}:accepted:${result.records}`;
  }

  return `${result.batchId}:rejected:${result.errors.join("|")}`;
}

const results: ImportResult[] = [
  { kind: "accepted", records: 18, batchId: "batch-4" },
  {
    kind: "rejected",
    errors: ["missing email", "invalid locale"],
    batchId: "batch-5",
  },
];

for (const result of results) {
  console.log(report(result));
}
batch-4:accepted:18
batch-5:rejected:missing email|invalid locale

batchId is available before narrowing because every path through the union has that intersection constraint. records and errors remain member-specific and become available only after checking kind.

The parentheses state the grouping and make the shared context’s scope visible to a maintainer. If the common part grows, give it a name instead of copying the same fields into every union member.

Pitfalls

Treating a union as a property collection

Fix: add a stable literal discriminant and narrow first. If both fields must always exist, the model needs an intersection or one complete object type, not a union.

Simulating distinct states with optional fields

Fix: define one complete member per state and distinguish them with the same literal field. Use ? only when a field is genuinely optional within that member.

Assuming & merges runtime objects

Fix: create the real value through an explicit object literal, reviewed spread, or constructor, then let the compiler check the result. Don’t use as A & B as an object-merge operation.

Ignoring overlapping intersection conflicts

Fix: review the overlap of keyof before composing types. When replacing a field, use Omit<Old, "id"> & NewId to state the intention and add tests for the runtime conversion.

Swallowing new members in a generic default branch

Fix: assign the remainder to never or pass it to assertNever(). Validate unknown external values before they enter the union instead of mixing them into static state handling through a default branch.

Asserting a union at an external boundary

Fix: put the parse result into unknown immediately, verify a non-null object, the exact discriminant, and the required fields for that member, then return the domain union. Give parse failures and unknown discriminants explicit outcomes.

Deep Type algebra and reduction

Type algebra and reduction

Unions and intersections absorb duplicate members: both T | T and T & T are equivalent to T. Member order normally doesn’t change meaning, so A | B and B | A, and A & B and B & A, describe the same value sets even when an editor displays them differently.

The compiler can reduce a composition when one member fully contains another. string | "ready" is string because the literal is already a string; string & "ready" is "ready" because it is the part satisfying both sides.

Because never has no ordinary runtime value, T | never is T and T & never is never. Because unknown can safely receive any value, T | unknown is unknown and T & unknown is T. These identities often appear in intermediate conditional and utility types.

The table collects these relationships. It describes static type reduction and performs no operation in JavaScript.

ExpressionReduced resultReason
T | neverTAdds no possible value
T & neverneverNo value can satisfy the empty set and another constraint
T | unknownunknownThe result accepts any unknown value
T & unknownTunknown adds no concrete requirement
string & "ready""ready"The literal is already a subset of strings

At the value-set level, intersection distributes over union, so A & (B | C) can be understood as (A & B) | (A & C). This explains a common way to add the same metadata to every event member: write the event union first, then intersect it with { requestId: string }.

Don’t force the compiler to expand every composition just to make its display attractive. Naming intermediate concepts, retaining discriminants, and testing public assignability relationships are more stable than depending on how an editor prints an alias.

These algebraic rules don’t prove runtime data trustworthy. Even when a complex type reduces correctly, a network value hasn’t been checked. The static set model applies only to evidence that has entered the type system.

Object unions preserve field relationships

The important value of an object union isn’t collecting keys; it is preserving relationships between fields. { format: "json"; payload: string } | { format: "binary"; payload: Uint8Array } says that format determines the type of payload.

Changing it to { format: "json" | "binary"; payload: string | Uint8Array } makes the two unions vary independently. The type then permits format: "json" with a binary payload, and checking format can no longer narrow payload.

Union members can share ordinary fields, such as requestId: string on every result. A shared field is directly readable, while member-specific fields still require narrowing. When the common part is large, name it and intersect it with each member.

A union isn’t exclusive-or. An object with enough structure may satisfy several members at once, so don’t rely on the assumption that it “belongs to only one interface.” Distinct literal discriminants establish a real mutual exclusion.

Designing usable intersections

Intersections work best for orthogonal constraints that can be explained independently. Identity, audit information, and pagination metadata can compose because each part has a clear owner. Types that both try to define core state are more likely to create naming and semantic conflicts.

Interface inheritance and intersections can both combine object requirements, but they fail differently. Interface inheritance rejects incompatible overlapping properties at the declaration. An intersection can form first and expose a conflicting property as never when used. For a stable public object hierarchy, the interface’s earlier failure is often clearer; for local type operations, an intersection is more flexible.

Using Omit before an intersection explicitly replaces a field, but it remains static modeling. If the old value’s field must change from a string to a number, runtime code must perform the conversion and handle failure; changing only the alias does nothing.

A long intersection often exposes an ownership problem. If one value must satisfy many unrelated capabilities, check whether a function is accepting a broader contract than it uses. Narrowing the parameter type is usually easier to test than adding another &.

Keys and call signatures

For object unions, keyof (A | B) keeps only keys that are safely present on every member; for object intersections, keyof (A & B) normally contains keys from both sides. This matches property access: a union consumer has only common guarantees, while an intersection consumer has both guarantees.

The types of overlapping properties must also account for read and write directions. Two declarations allowing a key to be read doesn’t mean any value accepted by either side can safely be written to it. Mutable objects, optional properties, and index signatures can defeat a simple “key merge” explanation.

An intersection of function types often behaves like a set of call signatures, and library declarations use it to describe a callable value that accepts several input shapes. The implementation must genuinely handle every signature; asserting a single-input function doesn’t generate overloaded behavior.

When a public API needs several clear call forms, function overloads are usually easier to read than a handwritten function intersection. Intersections better suit results derived by utility types, provided positive and negative type tests cover the call contract.

Static and runtime boundaries

Unions and intersections constrain only expressions checked by TypeScript. Values from JSON, DOM attributes, environment variables, or untyped packages must remain unknown until real runtime checks establish membership.

To validate a union, first check the container, then the discriminant, and finally the corresponding member’s payload. Merely finding a status property is insufficient: arrays, wrong field types, and inherited properties can still pass a loose check.

To validate an intersection, verify every condition promised by both sides. Combining two incomplete predicates with && doesn’t make either implementation more truthful. The compiler trusts type-predicate signatures, so tests must include missing and conflicting fields.

Keep runtime assertions and compile-time type cases together in tests. Runtime tests prove parsing and construction behavior; type tests prove which combinations are accepted and rejected. Neither side replaces the other.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?