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

  • 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

  • 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

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

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

Summary

Full-stack Software Developer that loves building products.