Creating Tagged Unions in TypeScript
Introduction to Tagged Unions
A tagged union is a data structure that holds several different data types, each of them distinguishable from one another using a discriminating property (usually called a “tag”). There exists a beautiful symmetry between tagged unions and object-oriented class hierarchies; one could even think that the tag in case of classes is defined within the virtual tables. Because of the aforementioned symmetry we could use certain design patterns for the tagged unions too, even if they were originally designed for class use only.
Tagged unions might be used in a couple of scenarios:
- abstracting away a certain result type — e.g. a function returning an engine might return different engines based on their types,
- abstracting away a certain argument type in a function — e.g. a function accepting gym members might handle certain member types differently.
Tagged unions are known under different names, most notably discriminated unions, variant and sum types.
Defining tagged unions in TypeScript
Let’s say we want to define a certain assembly language’s instructions (as an exercise, you might want to deduct what kind of application we would need them for):
export type NoOperation = Readonly<{
opCode: 'NOP';
}>;export type LogicalAndOperation = Readonly<{
opCode: 'AND';
source: string;
destination: string;
}>;export type LogicalOrOperation = Readonly<{
opCode: 'OR';
source: string;
destination: string;
}>;export type Operation =
NoOperation |
LogicalAndOperation |
LogicalOrOperation;export type OperationOpCode = Operation['opCode'];
Please note that:
- we didn’t use enums as the tag type, it’s just string literals,
- the tag type is defined where different operations are defined,
- the tagged type
Operation
is defined using type union operator|
, - the tag type
OperationOpCode
is defined based on the tagged type’s internal values.
The following approach means that adding and removing operations from the tagged union is done in exactly one place. Also, instead of using enums (which may or may not be transformed inline into their values during the compilation phase) we used string literals which are more terse and easier to manage.
It is possible to use generic tagged unions. In case of our example, we might decide we want to enable instructions that point to registers (strings) or memory addresses (numbers):
export type NoOperation<T extends number | string> = Readonly<{
opCode: 'NOP';
}>;export type LogicalAndOperation<T extends number | string> = Readonly<{
opCode: 'AND';
source: string;
destination: string;
}>;export type LogicalOrOperation<T extends number | string> = Readonly<{
opCode: 'OR';
source: string;
destination: string;
}>;export type Operation<T extends number | string> =
NoOperation<T> |
LogicalAndOperation<T> |
LogicalOrOperation<T>;export type OperationOpCode<T extends number | string> = Operations<T>['opCode'];
Inverted type definitions for Tagged Unions
Creating types might seems tedious if we have only way of creating them, for instance if we have a function that builds an instance from its arguments. For example, we can define a function that builds an instance of the LogicalAndOperation
type:
const buildLogicalAndOperation = (source: string, destination: string) => ({
opCode: 'AND',
source,
destination,
});
We can easily infer the return type from this definition like this:
const buildLogicalAndOperation = (source: string, destination: string) => Object.freeze({
opCode: 'AND' as const,
source,
destination,
});export type LogicalAndOperation = ReturnType<typeof buildLogicalAndOperation>;
It’s crucial to observe three changes here:
- any usage of
Object.freeze
is interpreted asReadonly
on the type level, - the string literal
'AND'
was force-cast asconst
— this is required for the TSC inferring mechanisms to understand theopCode
as a string literal instead of a mutable string, ReturnType
returns the inferred return type of a function.
This way we can define whole hierarchies without creating boilerplate code as TypeScript will create correct types for us. This method, however, won’t work with generic functions as it would appear we cannot leverage the typeof
operator for returning generic objects (whether this is a limitation of the type system of TypeScript or not is a another topic).
Summary
In this article we have learnt how to:
- create a tagged union by regular type definitions,
- abstract a tagged union using generics,
- infer types from builder functions to construct tagged unions,
As you probably have realised, tagged unions can be leveraged in many ways. In the following articles I want to explore how tagged unions can be efficiently used in TypeScript projects.