In this blog post, we explore what unions and intersections of object types can be used for in TypeScript.
In this blog post, object type means:
Record
)Unions of object types are often a good choice if a single type has multiple representations – e.g. a type Shape
that can be either a Triangle
, a Rectangle
or a Circle
:
type Shape = Triangle | Rectangle | Circle;
type Triangle = {
point1: Point,
point2: Point,
point3: Point,
};
type Rectangle = {
point1: Point,
point2: Point,
};
type Circle = {
center: Point,
radius: number,
};
type Point = {
x: number,
y: number,
};
The following types define a simple virtual file system:
type VirtualFileSystem = Map<string, FileEntry>;
type FileEntry = FileEntryData | FileEntryGenerator | FileEntryFile;
type FileEntryData = {
data: string,
};
type FileEntryGenerator = {
generator: (path: string) => string,
};
type FileEntryFile = {
path: string,
};
A function readFile()
for VirtualFileSystem
would work as follows (line A and line B):
const vfs: VirtualFileSystem = new Map([
[ '/tmp/file.txt',
{ data: 'Hello!' }
],
[ '/tmp/echo.txt',
{ generator: (path: string) => path }
],
]);
assert.equal(
readFile(vfs, '/tmp/file.txt'), // (A)
'Hello!'
);
assert.equal(
readFile(vfs, '/tmp/echo.txt'), // (B)
'/tmp/echo.txt'
);
This is an implementation of readFile()
:
import * as fs from 'node:fs';
function readFile(vfs: VirtualFileSystem, path: string): string {
const fileEntry = vfs.get(path);
if (fileEntry === undefined) {
throw new Error('Unknown path: ' + JSON.stringify(path));
}
if ('data' in fileEntry) { // (A)
return fileEntry.data;
} else if ('generator' in fileEntry) { // (B)
return fileEntry.generator(path);
} else if ('path' in fileEntry) { // (C)
return fs.readFileSync(fileEntry.path, 'utf-8');
} else {
throw new UnexpectedValueError(fileEntry); // (D)
}
}
Initially, the type of fileEntry
is FileEntry
and therefore:
FileEntryData | FileEntryGenerator | FileEntryFile
We have to narrow its type to one of the elements of this union type before we can access properties. And TypeScript lets us do that via the in
operator (line A, line B, line C).
Additionally, we check statically if we covered all possible cases, by checking if fileEntry
is assignable to the type never
(line D). That is done via the following class:
class UnexpectedValueError extends Error {
constructor(_value: never) {
super();
}
}
For more information on this technique and a longer and better implementation of UnexpectedValueError
, see the 2ality post “The bottom type never
in TypeScript”.
FileEntry
as a discriminated union A discriminated union is a union of object types that all have one property in common – whose value indicates the type of a union element. Let’s convert FileEntry
to a discriminated union:
type FileEntry =
| {
kind: 'FileEntryData',
data: string,
}
| {
kind: 'FileEntryGenerator',
generator: (path: string) => string,
}
| {
kind: 'FileEntryFile',
path: string,
}
;
type VirtualFileSystem = Map<string, FileEntry>;
The property of a discriminated union that has the type information is called a discriminant or a type tag. The discriminant of FileEntry
is .kind
. Other common names are .tag
, .key
and .type
.
On one hand, FileEntry
is more verbose now. On the other hand, discriminants give us several benefits – as we’ll see soon.
As an aside, discriminated unions are related to algebraic data types in functional programming languages. This is what FileEntry
looks like as an algebraic data type in Haskell (if the TypeScript union elements had more properties, we’d probably use records in Haskell).
data FileEntry = FileEntryData String
| FileEntryGenerator (String -> String)
| FileEntryFile String
readFile()
for the new FileEnty
Let’s adapt readFile()
to the new shape of FileEnty
:
function readFile(vfs: VirtualFileSystem, path: string): string {
const fileEntry = vfs.get(path);
if (fileEntry === undefined) {
throw new Error('Unknown path: ' + JSON.stringify(path));
}
switch (fileEntry.kind) {
case 'FileEntryData':
return fileEntry.data;
case 'FileEntryGenerator':
return fileEntry.generator(path);
case 'FileEntryFile':
return fs.readFileSync(fileEntry.path, 'utf-8');
default:
throw new UnexpectedValueError(fileEntry);
}
}
This brings us to a first advantage of discriminated unions: We can use switch
statements. And it’s immediately clear that .kind
distinguishes the type union elements – we don’t have to look for property names that are unique to elements.
Note that narrowing works as it did before: Once we have checked .kind
, we can access all relevant properties.
switch
statement.Another benefit is that, if the union elements are inlined (and not defined externally via types with names) then we can still see what each element does:
type Shape =
| {
tag: 'Triangle',
point1: Point,
point2: Point,
point3: Point,
}
| {
tag: 'Rectangle',
point1: Point,
point2: Point,
}
| {
tag: 'Circle',
center: Point,
radius: number,
}
;
Discriminated unions work even if all normal properties of union elements are the same:
type Temperature =
| {
type: 'TemperatureCelsius',
value: number,
}
| {
type: 'TemperatureFahrenheit',
value: number,
}
;
The following type definition is terse; but can you tell how it works?
type OutputPathDef =
| null // same as input path
| '' // stem of output path
| string // output path with different extension
If we use a discriminated union, the code becomes much more self-descriptive:
type OutputPathDef =
| { key: 'sameAsInputPath' }
| { key: 'inputPathStem' }
| { key: 'inputPathStemPlusExt', ext: string }
;
This is a function that uses OutputPathDef
:
import * as path from 'node:path';
function deriveOutputPath(def: OutputPathDef, inputPath: string): string {
if (def.key === 'sameAsInputPath') {
return inputPath;
}
const parsed = path.parse(inputPath);
const stem = path.join(parsed.dir, parsed.name);
switch (def.key) {
case 'inputPathStem':
return stem;
case 'inputPathStemPlusExt':
return stem + def.ext;
}
}
const zip = { key: 'inputPathStemPlusExt', ext: '.zip' } as const;
assert.equal(
deriveOutputPath(zip, '/tmp/my-dir'),
'/tmp/my-dir.zip'
);
In this section, we explore how we can derive types from discriminated unions. As an example, we work with the following discriminated union:
type Content =
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
| {
kind: 'video',
width: number,
height: number,
runningTimeInSeconds: number,
}
;
To extract the values of the discriminant, we can use a conditional type and the infer
keyword:
type GetKind<T extends {kind: string}> =
T extends {kind: infer S} ? S : never;
type ContentKind = GetKind<Content>;
type _ = Assert<Equal<
ContentKind,
'text' | 'image' | 'video'
>>;
Because conditional types are distributive over unions, GetKind
is applied to each element of Content
. infer
lets us extract the value of property .kind
.
If we use the type ContentKind
from the previous subsection, we can define an exhaustive map for the elements of Content
:
const DESCRIPTIONS_FULL: Record<ContentKind, string> = {
text: 'plain text',
image: 'an image',
video: 'a video',
} as const;
If the map should not be exhaustive, we can use the utility type Partial
:
const DESCRIPTIONS_PARTIAL: Partial<Record<ContentKind, string>> = {
text: 'plain text',
} as const;
Sometimes, we don’t need all of a discriminated union. We can write out own utility type for extracting a subtype of Content
:
type ExtractSubtype<
Union extends {kind: string},
SubKinds extends GetKind<Union> // (A)
> =
Union extends {kind: SubKinds} ? Union : never // (B)
;
We use a conditional type to loop over the union type U
:
.kind
of a union element has a type that is assignable to SubKinds
then we keep the element. If not then we omit it (by returning never
).extends
in line A ensures that we don’t make a typo when we extract: Our discriminant values SubKinds
must be a subset of GetKind<Union>
(see earlier subsection).Let’s use ExtractSubtype
:
type _ = Assert<Equal<
ExtractSubtype<Content, 'text' | 'image'>,
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
>>;
As an alternative to our own ExtractSubtype
, we can also use the built-in utility type Extract
:
type _ = Assert<Equal<
Extract<Content, {kind: 'text' | 'image'}>,
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
>>;
Extract
returns all elements of the union Content
that are assignable to the following type:
{kind: 'text' | 'image'}
To compare class hierarchies with discriminated unions, we use both to define syntax trees for representing expressions such as:
1 + 2 + 3
A syntax tree is either:
The following code uses an abstract class and two subclasses to represent syntax trees:
abstract class SyntaxTree {
abstract evaluate(): number;
}
class NumberValue extends SyntaxTree {
numberValue: number;
constructor(numberValue: number) {
super();
this.numberValue = numberValue;
}
evaluate(): number {
return this.numberValue;
}
}
class Addition extends SyntaxTree {
operand1: SyntaxTree;
operand2: SyntaxTree;
constructor(operand1: SyntaxTree, operand2: SyntaxTree) {
super();
this.operand1 = operand1;
this.operand2 = operand2;
}
evaluate(): number {
return this.operand1.evaluate() + this.operand2.evaluate();
}
}
The operation evaluate
handles the two cases “number value” and “addition” in the corresponding classes – via polymorphism. Here it is in action:
const syntaxTree = new Addition(
new NumberValue(1),
new Addition(
new NumberValue(2),
new NumberValue(3),
),
);
assert.equal(
syntaxTree.evaluate(), 6
);
The following code uses a discriminated union with two elements to represent syntax trees:
type SyntaxTree =
| {
kind: 'NumberValue';
numberValue: number;
}
| {
kind: 'Addition';
operand1: SyntaxTree;
operand2: SyntaxTree;
}
;
function evaluate(syntaxTree: SyntaxTree): number {
switch(syntaxTree.kind) {
case 'NumberValue':
return syntaxTree.numberValue;
case 'Addition':
return (
evaluate(syntaxTree.operand1) +
evaluate(syntaxTree.operand2)
);
default:
throw new UnexpectedValueError(syntaxTree);
}
}
The operation evaluate
handles the two cases “number value” and “addition” in a single location, via switch
. Here it is in action:
const syntaxTree: SyntaxTree = {
kind: 'Addition',
operand1: {
kind: 'NumberValue',
numberValue: 1,
},
operand2: {
kind: 'Addition',
operand1: {
kind: 'NumberValue',
numberValue: 2,
},
operand2: {
kind: 'NumberValue',
numberValue: 3,
},
}
};
assert.equal(
evaluate(syntaxTree), 6
);
We don’t need the type annotation in line A, but it helps ensure that the data has the correct structure. If we don’t do it here, we’ll find out about problems later.
With classes, we check the types of instances via instanceof
. With discriminated unions, we use discriminants to do so. In a way, they are runtime type information.
Each approach does one kind of extensibility well:
With classes, we have to modify each class if we want to add a new operation. However, adding a new type does not require any changes to existing code.
With discriminated unions, we have to modify each function if we want to add a new type. In contrast, adding new operations is simple.
The intersection of two object types has the properties of both:
type Obj1 = { prop1: boolean };
type Obj2 = { prop2: number };
const obj: Obj1 & Obj2 = {
prop1: true,
prop2: 123,
};
With interfaces, we can use extends
to add properties:
interface Person {
name: string;
}
interface Employe extends Person {
company: string;
}
With object types, we can use an intersection:
type Person = {
name: string,
};
type Employee =
& Person
& {
company: string,
}
;
One caveat is that only extends
supports overriding.
The following code shows how the inferred type of obj
changes when we use the built-in type guard in
(line A and line B):
function func(obj: object) {
if ('prop1' in obj) { // (A)
assertType<
object & Record<'prop1', unknown>
>(obj);
if ('prop2' in obj) { // (B)
assertType<
object & Record<'prop1', unknown> & Record<'prop2', unknown>
>(obj);
}
}
}
In the next example, we combine the type Obj
of a parameter with the type WithKey
– by adding the property .key
of WithKey
to the parameter:
type WithKey = {
key: string,
};
function addKey<Obj extends object>(obj: Obj, key: string)
: Obj & WithKey
{
const objWithKey = obj as (Obj & WithKey);
objWithKey.key = key;
return objWithKey;
}
addKey()
is used like this:
const paris = {
city: 'Paris',
};
const parisWithKey = addKey(paris, 'paris');
assertType<
{
city: string,
key: string,
}
>(parisWithKey);