Using Tagged Unions in TypeScript
This article is a continuation of the article on how to create tagged unions. If you haven’t read it yet, you can access it here.
Today I would like to go through some of the most typical implementations and usages of tagged unions in TypeScript projects. This is not going to be a comprehensive evaluation of these constructs but it should shed some light on the ways we can leverage them in modern web development.
Tag types
The most typical tag types that we can use are:
- string literals,
- number literals,
- enums,
- symbols.
If you want to know more on how literals work hand in hand with enums in TypeScript, you can read my article about enumeration types available here. The obvious benefit of having a symbol as the tag type is more control over how instances using tagged unions are created — the disadvantage being the lack of serialisability of such a construct.
Tag names
We can use:
- a string (most typical usage),
- a number (useful in limiting the size of arrays),
- a certain property being defined or not (to save some keystrokes).
It seems we cannot use symbols as tag names (verified on TS 3.8). If it was possible, the obvious advantage would be the fact that a tag defined in such a fashion would not be accessible by a simple object enumeration.
The choice of a tag name should be based on the purpose of the tagged union in question. For the vast majority of cases a string name would probably be the best choice. Checking if a certain property exists could be used in a couple of highly creative ways, one of which is going to be presented in this article.
Using type narrowing with tagged unions
Tagged unions allow us to apply a different behaviour (we could call it a strategy) for different types, using type narrowing within bodies of conditional statements. Assuming we want to have a function that consumes an operation, we can write:
const consumeOperation1 = (operation: Operation): void => {
if (operation.opCode === 'NOP') {
// the type of `operation` here is narrowed to `NoOperation`
return;
}if (operation.opCode === 'AND') {
// the type of `operation` here is narrowed to
// `LogicalAndOperation`
return;
}// the type of `operation` here is narrowed to
// `LogicalOrOperation`
};
Type narrowing also works with switch statements:
const consumeOperation2 = (operation: Operation): void => {
switch(operation.opCode) {
case "NOP":
console.log(operation);
break;
case "AND":
console.log(operation.destination);
break;
default:
console.log(operation.source);
}
}
If we were to exhaust all possible subtypes thanks to type narrowing, the variable type in question would be narrowed to the type with the lowest cardinality possible — the never type. In such a case, we can either ignore it as there is nothing meaningful to do with a never
— typed variable or we can throw an exception (or even return a left-sided Either instance if we are in a project that leverages functional programming).
Using type narrowing with compound tagged unions
For the purpose of this example let’s define four interfaces that would represent finite arrays that are either mutable or immutable. If you haven’t seen that approach before, this is the way we can represent a finite number of elements on an array in TypeScript. One of the usages is to distinguish between arrays from which we can get concrete results of queue-like operations as it is a case of theNonEmptyReadonlyArray
type and the methods like head
, last
, tail
, init
defined as a part of the fp-ts
library.
interface ImmutableOneElementArray<A> extends ReadonlyArray<A> {
readonly length: 1;
readonly 0: A;
}interface MutableOneElementArray<A> extends Array<A> {
length: 1;
0: A;
}interface ImmutableTwoElementArray<A> extends ReadonlyArray<A> {
readonly length: 2;
readonly 0: A;
readonly 1: A;
}interface MutableTwoElementArray<A> extends Array<A> {
length: 2;
0: A;
1: A;
}
These four interfaces are tagged by two separate tags, one being length
and the other being the fact whether the push
method is available on the interface in question. The latter tag points out that we don’t need to stick with traditional tags like numbers or strings as we can inspect objects for having certain properties using the forgotten in
operator.
We can now define a tagged union type with ease:
type ElementArray<A> =
| ImmutableOneElementArray<A>
| MutableOneElementArray<A>
| ImmutableTwoElementArray<A>
| MutableTwoElementArray<A>
;
If you are puzzled by seeing that the pipe
operator was applied before the first interface, don’t be — this is perfectly fine in TypeScript and I would advise for using this approach because it’s git-friendly, as we can clearly see when some new types are added and some old ones are removed.
To see the way tag narrowing works with a compound type union, let’s write a method that multiplies the last element of a numeric array (with either one or two elements, for that matter) and either mutates the input array to achieve that or returns a new one if the input array was immutable:
const multiplyLastElementByTwo = (
array: ElementArray<number>
): ElementArray<number> => {
if (array.length === 1) {
if ('push' in array) {
array[0] *= 2;
return array;
} return [ array[0] * 2 ];
} if ('push' in array) {
array[1] *= 2;
return array;
} return [ array[0], array[1] * 2 ];
};
The multiplyLastElementByTwo
function first checks the length of the input array (which constitutes the first occurrence of type narrowing) and later uses the in
operator to determine whether the array is mutable or not (which in turn is the second usage of type narrowing, resulting on one concrete type).
Using types, interfaces as vectors for tagged unions
As it must have been visible in the examples so far, we can define tagged unions using interface and types alike. The very tagged unions must of course be defined as types because of the mandatory usage of the pipe
operator. One thing worth noting though is, since interfaces can be extended everywhere in the project, some reckless changes to them might introduce incorrect behaviour into functions that depend on the already defined tagged unions.
Summary
Tagged unions allow for a lot of freedom in choosing the tag name, the tag type and whether the structure that uses them is a type or an interface. Thanks to type narrowing, we can efficiently use tagged unions without casting them to precise types. There is no doubt this feature could be a useful tool in an arsenal of an experienced software developer.