Unions and intersections of object types in TypeScript

[2025-03-04] dev, typescript
(Ad, please don’t block)

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:

  • Object literal type
  • Interface type
  • Mapped type (such as Record)

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 = {
  point1: Point,
  point2: Point,
  point3: Point,
};
type Rectangle = {
  point1: Point,
  point2: Point,
};
type Circle = {
  center: Point,
  radius: number,
};

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

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

Pros and cons of discriminated unions  

  • Con: Discriminating a union of object types makes it more verbose.
  • Pro: We can handle cases via a switch statement.
  • Pro: It’s immediately clear which property distinguishes the elements of the union.

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',
  point1: Point,
  point2: Point,
  point3: Point,
}
| {
  tag: 'Rectangle',
  point1: Point,
  point2: Point,
}
| {
  tag: 'Circle',
  center: Point,
  radius: number,
}
;

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

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

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

Extracting the values of the discriminant (the type tags)  

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.

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;

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:

  • Line B: If property .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).
  • The 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'}

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:

  • A number value
  • The addition of two syntax trees

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

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.

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:

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

Intersections of object types  

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

Extending vs. intersection  

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.

Example: inferred intersections  

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

Example: combining two object types via an intersection  

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

Further reading