HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

19 Unions of object types

In this chapter, we explore what unions of object types can be used for in TypeScript.

In this chapter, object type means:

19.1 From unions of object types to discriminated unions

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 = {
  corner1: Point,
  corner2: Point,
  corner3: Point,
};
type Rectangle = {
  corner1: Point,
  corner2: Point,
};
type Circle = {
  center: Point,
  radius: number,
};

type Point = {
  x: number,
  y: number,
};

19.1.1 Example: a union of objects

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 “Exhaustiveness checks” (§26.4.3.3).

19.1.2 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

19.1.4 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.

19.1.5 Pros and cons of discriminated unions

19.1.5.1 Pro: Inline union type elements come with descriptions

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',
  corner1: Point,
  corner2: Point,
  corner3: Point,
}
| {
  tag: 'Rectangle',
  corner1: Point,
  corner2: Point,
}
| {
  tag: 'Circle',
  center: Point,
  radius: number,
}
;
19.1.5.2 Pro: Union elements are not required to have unique properties

Discriminated unions work even if all normal properties of union elements are the same:

type Temperature =
  | {
    type: 'TemperatureCelsius',
    value: number,
  }
  | {
    type: 'TemperatureFahrenheit',
    value: number,
  }
;
19.1.5.3 General benefit of unions of object types: descriptiveness

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'
);

19.2 Deriving types from discriminated unions

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,
  }
;

19.2.1 Extracting the values of the discriminant (the type tags)

To extract the values of the discriminant, we can use an indexed access type (T[K]):

type GetKind<T extends {kind: string}> =
  T['kind'];

type ContentKind = GetKind<Content>;

type _ = Assert<Equal<
  ContentKind,
  'text' | 'image' | 'video'
>>;

Because indexed access types are distributive over unions, T['kind'] is applied to each element of Content and the result is a union of string literal types.

19.2.2 Maps for the elements of discriminated unions

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;

19.2.3 Extracting a subtype of a discriminated union

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:

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'}

19.3 Class hierarchies vs. discriminated unions

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:

19.3.1 A class hierarchy for syntax trees

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
);

19.3.2 A discriminated union for syntax trees

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.

19.3.3 Comparing classes and discriminated unions

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:

19.4 Defining discriminated unions via classes

It’s also possible to define a discriminated union via classes – e.g.:

type Color = Black | White;

abstract class AbstractColor {}
class Black extends AbstractColor {
  readonly kind = 'Black';
}
class White extends AbstractColor {
  readonly kind = 'White';
}

function colorToRgb(color: Color): string {
  switch (color.kind) {
    case 'Black':
      return '#000000';
    case 'White':
      return '#FFFFFF';
  }
}

Why would we want to do that? We can define and inherit methods for the elements of the union.

The abstract class AbstractColor is only needed if we want to share methods between the union classes.