Skip to content
academia.sh

Lesson 05 / 23

Union and Intersection Types

The shared-member rule in a union type, the discriminated union pattern, combining types with intersection, and the collapse of conflicting fields to the empty type.

Contents

The measurement record’s unit field was written as "C" | "Pa" | "%": a union of three types that are alternatives to one another. The same operation also has an intersection direction — values that carry the requirements of two types at once.

These two operators are the type-level counterpart of set algebra, and they frame the measurement record’s development from here on. This lesson covers which operations can be done on a union, the discriminant field pattern, and when an intersection comes up empty.

Union: The Sum of Values

Type A | B covers all values of type A or type B. At the level of value sets, this is a union operation:

values(AB)=values(A)values(B)\text{values}(A \mid B) = \text{values}(A) \cup \text{values}(B)

Code holding a union value does not know which member it came from. This is why it can only perform operations that exist in both members:

type NumericMeasurement = { id: string; value: number };
type TextualMeasurement = { id: string; text: string };
type Entry = NumericMeasurement | TextualMeasurement;

function write(entry: Entry): void {
  console.log(entry.id);
  console.log(entry.value);
}

write({ id: "s-01", value: 21.4 });
e1.ts(7,21): error TS2339: Property 'value' does not exist on type 'Entry'.
  Property 'value' does not exist on type 'TextualMeasurement'.

Access to id is valid, because it exists in both members. Access to value is rejected, because the value could be a TextualMeasurement. The diagnostic’s second line names which member is blocking it.

An inverse relationship follows from this, and it is surprising at first: as the value set grows, the usable member set shrinks. Adding a new member to a union reduces the operations that can be done with that type. In the language of sets: the union of the values is the intersection of the guaranteed members.

Discriminated Union

To access a member that exists in only one of a union’s members, it has to be proven which member the value came from. The most useful form of proof is putting a field with the same name but a different literal type on every member:

type Measurement =
  | { status: "valid"; id: string; value: number }
  | { status: "invalid"; id: string; code: string };

function summarize(measurement: Measurement): string {
  if (measurement.status === "valid") {
    return `${measurement.id}: ${measurement.value}`;
  }
  return `${measurement.id}: error ${measurement.code}`;
}

console.log(summarize({ status: "valid", id: "s-01", value: 21.4 }));
console.log(summarize({ status: "invalid", id: "s-02", code: "E17" }));

This file passes type checking and its output is:

s-01: 21.4
s-02: error E17

The status field is called the discriminant; this pattern is called a discriminated union. Three conditions are needed for it to work: the field’s name has to be the same across all members, its type has to be a literal type, and the literals have to differ between members.

When these conditions hold, the compiler accepts the check measurement.status === "valid" as proof and narrows the type to a single member inside the if body. This narrowing is called narrowing, and its rules are the subject of a separate lesson. Since "invalid" is the only member left after the if block, access to measurement.code is valid there too.

At this point the measurement record’s model makes its first structural decision: a valid and an invalid record are not two states of the same type, they are two separate types. This makes the value field accessible only in the state where it genuinely exists — the question “does a value exist” has moved from runtime to compile time.

Intersection: The Sum of Requirements

Type A & B covers values that are both A and B. For object types, this means merging the fields:

type Measurement = { id: string; value: number };
type Timestamped = { time: number };
type Entry = Measurement & Timestamped;

const entry: Entry = { id: "s-01", value: 21.4, time: 1706000000000 };
console.log(entry.id, entry.value, entry.time);

const missing: Entry = { id: "s-02", value: 22.1 };
e2.ts(8,7): error TS2322: Type '{ id: string; value: number; }' is not assignable to type 'Entry'.
  Property 'time' is missing in type '{ id: string; value: number; }' but required in type 'Timestamped'.

The inverse relationship from the union case reverses here: an intersection’s value set is smaller, and its usable member set is larger. An Entry value carries every field of both Measurement and Timestamped.

The typical use of intersection is keeping independently meaningful attributes in separate types and combining them where needed. In the measurement record, a timestamp, source information, and a validation result could each be defined separately; different contexts use different combinations. This is the type-level counterpart of the abstract data type idea introduced in the Data Structures course: a type is defined less by the fields it carries than by the contracts it satisfies.

When an Intersection Collapses

Intersection does not always produce a meaningful type. Two primitive types have no common value in their intersection:

type Impossible = string & number;

const empty: Impossible = "s-01";
console.log(empty);
e6.ts(3,7): error TS2322: Type '"s-01"' is not assignable to type 'never'.

Type string & number has been reduced to never — no value can be both a string and a number. The diagnostic speaking directly of never is the result of this collapse.

The same thing shows up in object types too, through a conflicting field:

type CelsiusRecord = { unit: "C"; value: number };
type PascalRecord = { unit: "Pa"; value: number };
type Both = CelsiusRecord & PascalRecord;

declare const record: Both;
const check: null = record;
const field: null = record.unit;
e4.ts(7,28): error TS2339: Property 'unit' does not exist on type 'never'.
  The intersection 'Both' was reduced to 'never' because property 'unit' has conflicting types in some constituents.

The diagnostic states the reason for the collapse outright: because the types of the unit field do not agree, the intersection has become never entirely. That the sixth line raises no error is a consequence of the same thing — never is assignable to every type.

This behavior is a design warning. If two types carrying conflicting fields need to be combined, the right tool is not intersection but union: values follow the rule of one or the other, not both at once.

Comparing the Two Operators

Criterion Union A | B Intersection A & B
Value set Union of sets A and B Intersection of sets A and B
Accessible members Only shared members All members
Before use Narrowing required Not required
Conflicting field Used as a discriminant Collapses to the empty type
Typical use The states a value can take Combining independent attributes

The table shows the two operators do not substitute for each other. Union is for modeling a choice: a record is either valid or invalid. Intersection is for modeling a sum: a record has both an id and a timestamp.

Summary

  • A union type’s value set is the union of its members; its accessible member set is the intersection of its members.
  • A discriminated union puts a field with the same name and a different literal type on every member; a check on that field enables narrowing.
  • An intersection type merges object types’ fields and imposes all requirements at once.
  • The intersection of two primitive types, and of object types carrying a conflicting field, collapses to never; the compiler states the reason in the diagnostic.
  • Union models a choice, intersection models a sum; for conflicting fields, the right tool is union.

Next Step

For the discriminated union to work, the fields had to be of a literal type. Yet as seen in the type inference lesson, an object literal’s fields are widened on their own and the literal type is lost. The next lesson covers the tools that stop this widening — literal types, the as const notation, and the satisfies operator that checks a type without narrowing it.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close