Dataslope logoDataslope

Unions and Intersections

Combining types with OR and AND to model flexible APIs and complex data shapes

So far, we've built types one piece at a time: primitives, objects, arrays, functions. But real-world APIs often need to express "this or that" (a value can be one of several types) or "this and that" (a value must satisfy multiple constraints at once). TypeScript provides union types (A | B) and intersection types (A & B) to handle these cases. They're the set-theoretic building blocks for expressing complex, precise types.

This chapter covers: union types, intersection types, set-theoretic intuition, when intersections produce never, and designing APIs with unions.

Union types: "this or that"

A union type A | B represents a value that is either type A or type B (or both, if they overlap):

Code Block
TypeScript 5.7

What the compiler now knows: value can be a string or a number, but nothing else. If you try to assign a boolean, the compiler rejects it.

Unions shine when a function can accept multiple input types:

Code Block
TypeScript 5.7

This is far more precise than using any (which disables all type checking) or creating separate overloads for string and number.

Working with union types: narrowing required

When you have a union, the compiler doesn't know which member it is at any given moment. So you can only call methods or access properties that exist on all members:

Code Block
TypeScript 5.7

The line value.length fails because number doesn't have a length property. To access length, you must narrow the type (we'll cover narrowing in detail in Type Narrowing):

Code Block
TypeScript 5.7

The typeof check tells the compiler "in this branch, value is a string." The compiler tracks this and allows value.length.

Intersection types: "this and that"

An intersection type A & B represents a value that is both type A and type B simultaneously:

Code Block
TypeScript 5.7

The type Person has both the name property from Named and the age property from Aged. Intersections merge object types.

Intersections are useful for mixins — combining multiple interfaces or types into one:

Code Block
TypeScript 5.7

The resulting type has all the properties from all three constituents.

Set-theoretic intuition: unions widen, intersections narrow

Think of types as sets of values. A union A | B is the union of the two sets (all values that are in A, or in B, or in both). An intersection A & B is the intersection of the two sets (only values that are in both A and B).

Unions widen the set of possible values: string | number accepts more values than just string or just number. Intersections narrow the set: Named & Aged accepts only values that satisfy both constraints.

For primitive types, intersections can produce an empty set:

Code Block
TypeScript 5.7

string & number is never (the bottom type) because no value can be both a string and a number. This is a compile-time error — the compiler knows the type is uninhabited.

Discriminated unions: tagged unions for type-safe variants

A discriminated union (also called a "tagged union") is a union of object types, each with a discriminator field (a literal type) that uniquely identifies the variant:

Code Block
TypeScript 5.7

The field kind is the discriminator. By checking shape.kind, the compiler narrows shape to the correct variant. This is incredibly powerful for modeling state machines, API responses, and other variant data.

What bugs does this prevent? Without the discriminator, you'd have to manually check which properties exist (e.g., "radius" in shape). With discriminated unions, the compiler does it for you, and the narrowing is exhaustive — if you add a new variant, the compiler will tell you everywhere you need to handle it.

Exhaustiveness checking with discriminated unions

When you handle all cases of a discriminated union, the compiler can verify that you haven't missed any. This is called exhaustiveness checking:

Code Block
TypeScript 5.7

If you add a fourth variant (say, Timeout) to Result, the compiler will complain in handleResult — the else branch no longer handles all cases. This forces you to update the code.

A common idiom for exhaustiveness checking is to use a never-typed variable in the final else:

Code Block
TypeScript 5.7

If status is not fully handled, the line const _exhaustive: never = status will error, because status won't be never (it will be the unhandled variant).

Union of function types

You can also have unions of function types, though this is less common:

Code Block
TypeScript 5.7

When calling a Func, the compiler doesn't know which variant it is, so you can only call it with arguments that satisfy all overloads (in this case, none exist, so it's tricky to use). Function unions are often better expressed with overloads or generics.

Intersection of object types: merging properties

When you intersect two object types, the result has all properties from both:

Code Block
TypeScript 5.7

This is equivalent to declaring an interface that extends both:

Code Block
TypeScript 5.7

Both approaches are valid; choose based on style and whether you need type features (like unions).

When intersections conflict: never

If you intersect two types with conflicting properties, the result is never:

Code Block
TypeScript 5.7

The property value must be both string and number, which is impossible. So Conflict is effectively never. The compiler will catch this at declaration time in most cases.

Designing APIs with unions: flexibility and safety

Unions let you model flexible APIs without sacrificing type safety. For example, a function that accepts a configuration object or a simple string:

Code Block
TypeScript 5.7

This is more ergonomic than forcing callers to always provide a full Config object, but still type-safe — you can't pass a boolean or an array.

Practice: a result type with discriminated union

Let's solidify these concepts with a challenge. You'll define a Result type for error handling (similar to Rust's Result<T, E>), then implement functions that construct and consume it.

Challenge
TypeScript 5.7
Result Type for Error Handling

Define a discriminated union Result<T> = { ok: true; value: T } | { ok: false; error: string }. Implement two functions: success<T>(value: T): Result<T> (constructs a success result) and unwrap<T>(result: Result<T>): T (returns the value if ok, throws an error otherwise). Test with unwrap(success(42)) (should return 42) and unwrap({ ok: false, error: "fail" }) (should throw).

Check your understanding

QuestionSelect one

What does the union type string | number represent?

A value that is both a string and a number

A value that is neither a string nor a number

A value that is either a string or a number

A value that is only a string

QuestionSelect one

What does the intersection type A & B represent for two object types A and B?

A value that is either A or B

A value that has all properties from both A and B

A value that has no properties

A value that is compatible with neither A nor B

Summary

Union types (A | B) let you express "this or that," widening the set of acceptable values. Intersection types (A & B) let you express "this and that," narrowing the set to values that satisfy both constraints. Discriminated unions (tagged unions) use literal types as discriminators to enable type-safe variant handling with exhaustiveness checking.

Unions are essential for flexible APIs: a function can accept multiple input types without losing precision. Intersections are essential for mixins and combining constraints. Together, they're the building blocks for complex, expressive type systems.

In the next chapter, Type Narrowing, we'll see how the compiler tracks control flow to refine union types — how typeof, instanceof, in, and custom type guards let you safely work with values of union types.

On this page