A recent blog post explored how TypeScript enums work. In this blog post, we take a look at alternatives to enums.
An alternative to creating an enum that maps keys to values, is to create a union of singleton types (one per value). Read on to see how that works.
Let’s start with an enum and convert it to a union of string literal types.
enum NoYesEnum {
No = 'No',
Yes = 'Yes',
}
function toGerman(value: NoYesEnum): string {
switch (value) {
case NoYesEnum.No:
return 'Nein';
case NoYesEnum.Yes:
return 'Ja';
}
}
assert.equal(toGerman(NoYesEnum.No), 'Nein');
assert.equal(toGerman(NoYesEnum.Yes), 'Ja');
An alternative is to use a type union:
type NoYesStrings = 'No' | 'Yes';
|
is related to the set-theoretic union operator ∪
.In this case, the operands of the type union are string literal types:
'No'
is the type whose only element is the string 'No'
'Yes'
is the type whose only element is the string 'Yes'
These types are specified with the same syntax as string literals, but they exist at a different level:
Let’s use NoYesStrings
in an example:
function toGerman(value: NoYesStrings): string {
switch (value) {
case 'No':
return 'Nein';
case 'Yes':
return 'Ja';
}
}
assert.equal(toGerman('No'), 'Nein');
assert.equal(toGerman('Yes'), 'Ja');
The following code demonstrates that TypeScript checks exhaustiveness for unions of string literal types:
// @ts-ignore Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman2(value: NoYesStrings): string {
switch (value) {
case 'Yes':
return 'Ja';
}
}
We forgot the case 'No'
and TypeScript warns us that the function is not guaranteed to only return strings, anymore.
One downside of string literal unions is that non-member values can mistakenly be considered to be members:
type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';
const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;
This is logical because the Spanish 'no'
and the English 'no'
are the same value. The real problem is that there is no way to give them different identities.
Instead of unions of string literal types, we can also use unions of symbol singleton types. Let’s start with a different enum this time:
enum LogLevel {
off = 'off',
info = 'info',
warn = 'warn',
error = 'error',
}
Translated to a union of symbol singleton types, it looks as follows:
const off = Symbol('off');
const info = Symbol('info');
const warn = Symbol('warn');
const error = Symbol('error');
type LogLevel =
| typeof off
| typeof info
| typeof warn
| typeof error
;
The following function translates members of LogLevel
to strings:
function toString(logLevel: LogLevel): string {
switch (logLevel) {
case off:
return 'off';
case info:
return 'info';
case warn:
return 'warn';
case error:
return 'error';
}
}
Let’s compare this approach to unions of string literal types:
The last point is demonstrated in the following example:
const spanishNo = Symbol('no');
const spanishSí = Symbol('sí');
type Spanish = typeof spanishNo | typeof spanishSí;
const englishNo = Symbol('no');
const englishYes = Symbol('yes');
type English = typeof englishNo | typeof englishYes;
const spanishWord: Spanish = spanishNo;
// @ts-ignore: Type 'unique symbol' is not assignable to type 'English'. (2322)
const englishWord: English = spanishNo;
Type unions and enums have some things in common:
But they also differ. Downsides of unions of symbol singleton types are:
Upsides of unions of symbol singleton types are:
Discriminated unions are related to algebraic data types in functional programming languages.
To understand how they work, consider the data structure syntax tree that represents expressions such as:
1 + 2 + 3
A syntax tree is either:
We’ll start by creating an object-oriented class hierarchy. Then we’ll transform it into something slightly more functional. And finally, we’ll end up with a discriminated union.
This is a typical OOP implementation of a syntax tree:
// Abstract = can’t be instantiated via `new`
abstract class SyntaxTree1 {}
class NumberValue1 extends SyntaxTree1 {
constructor(public numberValue: number) {
super();
}
}
class Addition1 extends SyntaxTree1 {
constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
super();
}
}
SyntaxTree1
is the superclass of NumberValue1
and Addition1
. The keyword public
is syntactic sugar for:
.numberValue
numberValue
This is an example of using SyntaxTree1
:
const tree = new Addition1(
new NumberValue1(1),
new Addition1(
new NumberValue1(2),
new NumberValue1(3), // trailing comma
), // trailing comma
);
Note: Trailing commas in argument lists are allowed in JavaScript since ECMAScript 2016.
If we define the syntax tree via a type union (line A), we don’t need object-oriented inheritance:
class NumberValue2 {
constructor(public numberValue: number) {}
}
class Addition2 {
constructor(public operand1: SyntaxTree2, public operand2: SyntaxTree2) {}
}
type SyntaxTree2 = NumberValue2 | Addition2; // (A)
Since NumberValue2
and Addition2
don’t have a superclass, they don’t need to invoke super()
in their constructors.
Interestingly, we create trees in the same manner as before:
const tree = new Addition2(
new NumberValue2(1),
new Addition2(
new NumberValue2(2),
new NumberValue2(3), // trailing comma
), // trailing comma
);
Finally, we get to discriminated unions. These are the type definitions for SyntaxTree3
:
interface NumberValue3 {
type: 'number-value';
numberValue: number;
}
interface Addition3 {
type: 'addition';
operand1: SyntaxTree3;
operand2: SyntaxTree3;
}
type SyntaxTree3 = NumberValue3 | Addition3;
We have switched from classes to interfaces and therefore from instances of classes to plain objects.
The interfaces of a discriminated union must have at least one property in common and that property must have a different value for each one of them. That property is called the discriminant or tag. Compare:
This is a member of SyntaxTree3
:
const tree: SyntaxTree3 = { // (A)
type: 'addition',
operand1: {
type: 'number-value',
numberValue: 1,
},
operand2: {
type: 'addition',
operand1: {
type: 'number-value',
numberValue: 2,
},
operand2: {
type: 'number-value',
numberValue: 3,
},
}
};
We don’t need the type annotation in line A, but it helps ensure that all objects have the correct structure. If we don’t do it here, we’ll find out about problems later.
TypeScript tracks the value of the discriminant and updates the type of the member of the union accordingly:
function getNumberValue(tree: SyntaxTree3) {
// %inferred-type: SyntaxTree3
tree; // (A)
// @ts-ignore: Property 'numberValue' does not exist on type 'SyntaxTree3'.
// Property 'numberValue' does not exist on type 'Addition3'.(2339)
tree.numberValue; // (B)
if (tree.type === 'number-value') {
// %inferred-type: NumberValue3
tree; // (C)
return tree.numberValue; // OK!
}
return null;
}
In line A, we haven’t checked the discriminant .type
, yet. Therefore, the current type of tree
is still SyntaxTree3
and we can’t access property .numberValue
in line B.
In line C, TypeScript knows that .type
is 'number-value'
and can therefore infer the type NumberValue3
for tree
. That’s why accessing .numberValue
in the next line is OK, this time.
We conclude this step with an example of how to implement functions for discriminated unions.
If there is an operation that can be applied to members of all subtypes, the approaches for classes and discriminated unions differ:
The following example demonstrates the latter approach. The discriminant is examined in line A and determines which of the two switch
cases is executed.
function syntaxTreeToString(tree: SyntaxTree3): string {
switch (tree.type) { // (A)
case 'addition':
return syntaxTreeToString(tree.operand1)
+ ' + ' + syntaxTreeToString(tree.operand2);
case 'number-value':
return String(tree.numberValue);
}
}
assert.equal(syntaxTreeToString(tree), '1 + 2 + 3');
Note that TypeScript performs exhaustiveness checking for discriminated unions: If we forget a case, TypeScript will warn us.
abstract class SyntaxTree1 {
// Abstract = enforce that all subclasses implement this method:
abstract toString(): string;
}
class NumberValue1 extends SyntaxTree1 {
constructor(public numberValue: number) {
super();
}
toString(): string {
return String(this.numberValue);
}
}
class Addition1 extends SyntaxTree1 {
constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
super();
}
toString(): string {
return this.operand1.toString() + ' + ' + this.operand2.toString();
}
}
const tree = new Addition1(
new NumberValue1(1),
new Addition1(
new NumberValue1(2),
new NumberValue1(3),
),
);
assert.equal(tree.toString(), '1 + 2 + 3');
Note that with the OOP approach, we had to modify the classes in order to add functionality. In contrast, with the functional approach, external parties can add functionality.
Discriminated unions and normal type unions have two things in common:
The next two subsections explore two advantages of discriminated unions over normal unions:
With discriminated unions, values get descriptive property names. Let’s compare:
Normal union:
type FileGenerator = (webPath: string) => string;
type FileSource1 = string|FileGenerator;
Discriminated union:
interface FileSourceFile {
type: 'FileSourceFile',
nativePath: string,
}
interface FileSourceGenerator {
type: 'FileSourceGenerator',
fileGenerator: FileGenerator,
}
type FileSource2 = FileSourceFile | FileSourceGenerator;
Now people who read the source code immediately know what the string is: a native pathname.
The following discriminated union cannot be implemented as a normal union because we can’t distinguish the types of the union in TypeScript.
interface TemperatureCelsius {
type: 'TemperatureCelsius',
value: number,
}
interface TemperatureFahrenheit {
type: 'TemperatureFahrenheit',
value: number,
}
type Temperature = TemperatureCelsius | TemperatureFahrenheit;
The following pattern for implementing enums is common in JavaScript:
const Color = {
red: Symbol('red'),
green: Symbol('green'),
blue: Symbol('blue'),
};
We can attempt to use it in TypeScript as follows:
// %inferred-type: symbol
type TColor = (typeof Color)[keyof typeof Color]; // (A)
function toGerman(color: TColor): string {
switch (color) {
case Color.red:
return 'rot';
case Color.green:
return 'grün';
case Color.blue:
return 'blau';
default:
// No exhaustiveness check (inferred type should be `never`):
// %inferred-type: symbol
color;
// Prevent static error for return type:
throw new Error();
}
}
Alas, in line A, TypeScript infers the type symbol
. Accordingly, we can pass any symbol to toGerman()
and TypeScript won’t complain at compile time:
assert.equal(
toGerman(Color.green), 'grün');
assert.throws(
() => toGerman(Symbol())); // no static error!
We can try and define TColor
differently, but that doesn’t change anything:
// %inferred-type: symbol
type TColor2 =
| typeof Color.red
| typeof Color.green
| typeof Color.blue
;
In contrast, if we use constants instead of properties, we get a union between three different values:
const red = Symbol('red');
const green = Symbol('green');
const blue = Symbol('blue');
// %inferred-type: unique symbol | unique symbol | unique symbol
type TColor2 = typeof red | typeof green | typeof blue;
const Color = {
red: 'red',
green: 'green',
blue: 'blue',
} as const; // (A)
// %inferred-type: "red" | "green" | "blue"
type TColor = (typeof Color)[keyof typeof Color];
We need as const
in line A so that TColor
isn’t string
. Alas, it doesn’t change anything if the property values are symbols.
Using string-valued properties is:
Upsides:
Downsides:
The following example demonstrates a Java-inspired enum pattern that works in plain JavaScript and TypeScript:
class Color {
static red = new Color();
static green = new Color();
static blue = new Color();
}
// @ts-ignore: Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman(color: Color): string { // (A)
switch (color) {
case Color.red:
return 'rot';
case Color.green:
return 'grün';
case Color.blue:
return 'blau';
}
}
assert.equal(toGerman(Color.blue), 'blau');
Alas, TypeScript doesn’t perform exhaustiveness checks, which is why we get an error in line A.
The following table summarizes the characteristics of enums and their alternatives in TypeScript:
Unique | Namesp. | Iter. | Mem. CT | Mem. RT | Exhaust. | |
---|---|---|---|---|---|---|
Number enums | - |
✔ |
✔ |
✔ |
- |
✔ |
String enums | ✔ |
✔ |
✔ |
✔ |
- |
✔ |
String unions | - |
- |
- |
✔ |
- |
✔ |
Symbol unions | ✔ |
- |
- |
✔ |
- |
✔ |
Discrim. unions | - (1) |
- |
- |
✔ |
- (2) |
✔ |
Symbol properties | ✔ |
✔ |
✔ |
- |
- |
- |
String properties | - |
✔ |
✔ |
✔ |
- |
✔ |
Enum pattern | ✔ |
✔ |
✔ |
✔ |
✔ |
- |
Titles of table columns:
instanceof
.Footnotes in table cells:
TColor
for an object literal.