TypeScript Intersection Types

An intersection type combines multiple types into a single type that has all the members of every constituent type. Where a union type (A | B) means “a value that is A, or B, or both”, an intersection type (A & B) means “a value that satisfies A and B at the same time”. Intersections are TypeScript’s native way to compose object shapes, merge mixins, and build complex types out of small, reusable pieces without relying on class inheritance.

Overview: How Intersection Types Work

You create an intersection type with the & operator between two or more types: type C = A & B. The resulting type C requires every property that A requires and every property that B requires. If A has a name: string field and B has an age: number field, a value typed as A & B must supply both name and age.

This can feel backwards at first — intersecting two sets usually makes the result smaller, but intersecting two object types makes the result stricter, and in terms of required properties, bigger. The name makes sense once you think in terms of the set of values that satisfy the type, rather than the set of property names. The values that satisfy both A and B are exactly the intersection (in the set-theory sense) of “values satisfying A” and “values satisfying B” — and any value in that intersection happens to carry the properties of both types.

TypeScript computes intersections structurally, member by member. When both constituent types declare the same property name with compatible types, the resulting property keeps that (possibly narrowed) type. When they declare the same property name with incompatible types, TypeScript intersects the property’s type too — and intersecting incompatible primitive types like string & number produces never, the type with no possible values. That single rule explains most of the confusing edge cases you’ll hit with intersections, and it’s covered in depth in the Common Mistakes section below.

Intersections vs. Unions

Aspect Union A | B Intersection A & B
Meaning Value is A, or B, or both Value is A and B simultaneously
Required properties Only properties common to the narrowed branch All properties from every constituent
Typical use “one of several possible shapes” (often discriminated) “combine several capabilities into one” (mixins, extension)
Conflicting primitive members Keeps both as separate branches Collapses to never

Intersections distribute over unions

If one side of an intersection is itself a union, TypeScript distributes the intersection across each branch: (A | B) & C becomes (A & C) | (B & C). This is genuinely useful — it lets you “tag” every variant of a discriminated union with shared fields (like an id or timestamp) using a single intersection, without writing the shared fields into every branch by hand. See the Under the Hood example below for this in action.

Syntax

type IntersectionName = TypeA & TypeB;
type ThreeWay = TypeA & TypeB & TypeC;
  • type IntersectionName — the alias you’re defining; intersections are almost always written behind a type alias for readability, since you can’t declare an intersection with interface directly (though an interface can extends multiple interfaces to similar effect).
  • TypeA & TypeB — any two or more types: interfaces, object type literals, type aliases, generics, even primitives.
  • & — the intersection operator; chain as many as you like. Order does not change the resulting shape.

Here it is applied to three small interfaces, merged into one:

interface A {
  a: string;
}

interface B {
  b: number;
}

interface C {
  c: boolean;
}

type AB = A & B;
type ABC = A & B & C;

const example: ABC = { a: "x", b: 1, c: true };
console.log(example);

Output:

{ a: 'x', b: 1, c: true }

Every property from A, B, and C is required on example — omit any one of them and tsc reports a missing-property error.

Examples

Example 1: Merging two simple interfaces

interface Named {
  name: string;
}

interface Aged {
  age: number;
}

type Person = Named & Aged;

const person: Person = {
  name: "Ava",
  age: 34,
};

console.log(`${person.name} is ${person.age} years old.`);

Output:

Ava is 34 years old.

Person is not a new, independent type — it’s exactly Named and Aged fused together. Any object assigned to a Person-typed variable must have both a name and an age.

Example 2: Composing a domain type from reusable pieces

A common real-world use is layering cross-cutting fields (timestamps, IDs, audit info) onto a core entity type without duplicating them everywhere:

interface Timestamped {
  createdAt: Date;
}

interface Product {
  id: string;
  price: number;
}

type TimestampedProduct = Product & Timestamped;

function logProduct(p: TimestampedProduct): void {
  console.log(`${p.id}: $${p.price} (added ${p.createdAt.toISOString()})`);
}

const product: TimestampedProduct = {
  id: "sku-100",
  price: 25,
  createdAt: new Date("2024-01-01T00:00:00.000Z"),
};

logProduct(product);

Output:

sku-100: $25 (added 2024-01-01T00:00:00.000Z)

Timestamped can be reused on any number of entities (TimestampedOrder, TimestampedUser, …) instead of copy-pasting a createdAt field into every interface.

Example 3: A generic merge function returning an intersection

Intersections show up naturally whenever you write a function that combines two objects into one, such as a small mixin helper:

function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

interface Position {
  x: number;
  y: number;
}

interface Velocity {
  vx: number;
  vy: number;
}

const sprite = merge<Position, Velocity>({ x: 0, y: 0 }, { vx: 1, vy: -1 });

console.log(sprite.x, sprite.y, sprite.vx, sprite.vy);

Output:

0 0 1 -1

TypeScript’s object-spread inference understands generic spreads: spreading two values of generic types T and U produces a value typed T & U, which matches the function’s declared return type with no manual casting needed.

Under the Hood

Two things are worth internalizing about how the compiler treats intersections:

1. Intersections are erased at runtime. Like every TypeScript type, A & B exists only during type checking. Once compiled to JavaScript, the type annotations disappear entirely — what remains at runtime is a plain object with whatever properties you actually put on it. The compiler’s job is purely to verify, at compile time, that every value you assign to an intersection-typed variable actually carries all the required properties.

2. Intersections distribute over unions. This lets you attach shared fields to every branch of a discriminated union in one line:

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
type Tagged = { id: string };

type TaggedShape = Shape & Tagged;

const t: TaggedShape = { kind: "circle", radius: 3, id: "s-1" };
console.log(t);

Output:

{ kind: 'circle', radius: 3, id: 's-1' }

TaggedShape is really (Circle & Tagged) | (Square & Tagged) under the hood — every branch of Shape individually picks up the id field, and you can still narrow on kind as usual.

Common Mistakes

Mistake 1: Intersecting incompatible primitive types silently produces never

It’s tempting to read string & number as “accepts a string or a number”, but that’s what a union means. An intersection requires a single value to be both a string and a number at once — impossible — so TypeScript collapses it to never:

function printId(id: string & number): void {
  console.log(id);
}

printId(42);

tsc reports: Argument of type 'number' is not assignable to parameter of type 'never'. The parameter type itself is legal to declare, but no value can ever satisfy it, so every call fails. The fix is to use a union when you mean “either type is acceptable”:

function printId(id: string | number): void {
  console.log(id);
}

printId(42);
printId("abc-42");

Output:

42
abc-42

Mistake 2: Reaching for & when you meant “one of several variants”

The same trap appears with object types when you actually wanted a discriminated union but typed & out of habit:

interface CircleShape {
  kind: "circle";
  radius: number;
}

interface SquareShape {
  kind: "square";
  side: number;
}

type Shape = CircleShape & SquareShape;

const shape: Shape = { kind: "circle", radius: 5, side: 2 };

tsc reports: Type '"circle"' is not assignable to type 'never'. Because both interfaces declare kind with different literal types, the intersected kind becomes "circle" & "square", which is never — and now the type is impossible to construct no matter what you pass. A shape is either a circle or a square, never both, so this calls for a union:

interface CircleShape {
  kind: "circle";
  radius: number;
}

interface SquareShape {
  kind: "square";
  side: number;
}

type Shape = CircleShape | SquareShape;

function area(shape: Shape): number {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
  return shape.side ** 2;
}

const circle: Shape = { kind: "circle", radius: 5 };
console.log(area(circle));

Output:

78.53981633974483

As a rule of thumb: if a value’s properties genuinely differ based on some tag or kind, you want a union. If you’re layering independent capabilities onto the same value at the same time, you want an intersection.

Best Practices

  • Reach for intersections to compose capabilities (mixins, shared metadata, cross-cutting fields), and reach for unions to model variants (one of several possible shapes).
  • Keep the pieces you intersect small and single-purpose (Timestamped, Identifiable, Named) so they’re reusable across many entity types.
  • Watch for accidental property-name collisions between the types you intersect — if two pieces both declare a property with incompatible types, the merged property silently becomes never instead of raising an obvious error at the declaration site.
  • Prefer a type alias for the intersection itself, even if the pieces being combined are interfaces — interface declarations can’t express an intersection directly.
  • When you need a fixed, closed set of mutually exclusive object shapes, use a discriminated union rather than trying to force intersections to do that job.
  • Remember intersections are purely a compile-time construct — always build the actual merged object at runtime yourself (object spread, Object.assign, or explicit object literals); the type does not merge anything for you.

Practice Exercises

  1. Define two interfaces, Serializable (with a toJSON(): string method) and Loggable (with a log(): void method). Create an intersection type Auditable from both, then write an object literal that satisfies it.
  2. Given interface Draft { status: "draft"; } and interface Published { status: "published"; publishedAt: Date; }, explain (and try in your editor) what happens if you write type Post = Draft & Published instead of Draft | Published. What does tsc report, and why?
  3. Write a generic function withId<T extends object>(value: T): T & { id: string } that takes any object and returns it merged with a new id field. Call it on a plain object and log the result.

Summary

  • An intersection type A & B requires a value to satisfy every constituent type at once — it has all the members of A and all the members of B.
  • Intersections are written with the & operator, usually behind a type alias, and can chain more than two types.
  • Intersecting incompatible primitive or literal member types collapses that member to never, making the whole type impossible to construct — a frequent source of confusing errors.
  • Intersections distribute over unions: (A | B) & C becomes (A & C) | (B & C), which is handy for tagging every branch of a discriminated union with shared fields.
  • Use intersections to compose capabilities (mixins, shared metadata); use unions to model mutually exclusive variants.
  • Like all TypeScript types, intersections are fully erased at runtime — they only affect compile-time checking, never the emitted JavaScript.