The previous chapter explored how TypeScript enums work. In this chapter, we take a look at alternatives to enums.
An enum maps member names to member values. If we don’t need or want the indirection, we can use a union of so-called primitive literal types – one per value. Before we can go into details, we need to learn about primitive literal types.
Quick recap: We can consider types to be sets of values.
A singleton type is a type with one element. Primitive literal types are singleton types:
type UndefinedLiteralType = undefined;
type NullLiteralType = null;
type BooleanLiteralType = true;
type NumericLiteralType = 123;
type BigIntLiteralType = 123n; // --target must be ES2020+
type StringLiteralType = 'abc';
UndefinedLiteralType
is the type with the single element undefined
, etc.
It is important to be aware of the two language levels at play here (we have already encountered those levels earlier in this book). Consider the following variable declaration:
: 'abc' = 'abc'; const abc
'abc'
represents a type (a string literal type).'abc'
represents a value.Two use cases for primitive literal types are:
Overloading on string parameters which enables the first argument of the following method call to determine the type of the second argument:
.addEventListener('click', myEventHandler); elem
We can use a union of primitive literal types to define a type by enumerating its members:
type IceCreamFlavor = 'vanilla' | 'chocolate' | 'strawberry';
Read on for more information about the second use case.
We’ll start with an enum and convert it to a union of string literal types.
enum NoYesEnum {= 'No',
No = 'Yes',
Yes
}function toGerman1(value: NoYesEnum): string {
switch (value) {
.No:
case NoYesEnum'Nein';
return .Yes:
case NoYesEnum'Ja';
return
}
}.equal(toGerman1(NoYesEnum.No), 'Nein');
assert.equal(toGerman1(NoYesEnum.Yes), 'Ja'); assert
NoYesStrings
is the union type version of NoYesEnum
:
type NoYesStrings = 'No' | 'Yes';
function toGerman2(value: NoYesStrings): string {
switch (value) {
'No':
case 'Nein';
return 'Yes':
case 'Ja';
return
}
}.equal(toGerman2('No'), 'Nein');
assert.equal(toGerman2('Yes'), 'Ja'); assert
The type NoYesStrings
is the union of the string literal types 'No'
and 'Yes'
. The union type operator |
is related to the set-theoretic union operator ∪
.
The following code demonstrates that exhaustiveness checks work for unions of string literal types:
// @ts-expect-error: Function lacks ending return statement and
// return type does not include 'undefined'. (2366)
function toGerman3(value: NoYesStrings): string {
switch (value) {
'Yes':
case 'Ja';
return
} }
We forgot the case for 'No'
and TypeScript warns us that the function may return values that are not strings.
We could have also checked exhaustiveness more explicitly:
class UnsupportedValueError extends Error {constructor(value: never) {
super('Unsupported value: ' + value);
}
}
function toGerman4(value: NoYesStrings): string {
switch (value) {
'Yes':
case 'Ja';
return default:
// @ts-expect-error: Argument of type '"No"' is not
// assignable to parameter of type 'never'. (2345)
new UnsupportedValueError(value);
throw
} }
Now TypeScript warns us that we reach the default
case if value
is 'No'
.
More information on exhaustiveness checking
For more information on this topic, see §12.7.2.2 “Protecting against forgetting cases via exhaustiveness checks”.
One downside of string literal unions is that non-member values can mistaken for members:
type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';
: Spanish = 'no';
const spanishWord: English = spanishWord; const englishWord
This is logical because the Spanish 'no'
and the English 'no'
are the same value. The actual problem is that there is no way to give them different identities.
LogLevel
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:
= Symbol('off');
const off = Symbol('info');
const info = Symbol('warn');
const warn = Symbol('error');
const error
// %inferred-type: unique symbol | unique symbol |
// unique symbol | unique symbol
type LogLevel =
| typeof off
| typeof info
| typeof warn
| typeof error
;
Why do we need typeof
here? off
etc. are values and can’t appear in type equations. The type operator typeof
fixes this issue by converting values to types.
Let’s consider two variations of the previous example.
Can we inline the symbols (instead of referring to separate const
declarations)? Alas, the operand of the type operator typeof
must be an identifier or a “path” of identifiers separated by dots. Therefore, this syntax is illegal:
type LogLevel = typeof Symbol('off') | ···
let
instead of const
Can we use let
instead of const
to declare the variables? (That’s not necessarily an improvement but still an interesting question.)
We can’t because we need the narrower types that TypeScript infers for const
-declared variables:
// %inferred-type: unique symbol
= Symbol('constSymbol');
const constSymbol
// %inferred-type: symbol
= Symbol('letSymbol1'); let letSymbol1
With let
, LogLevel
would simply have been an alias for symbol
.
const
assertions normally solve this kind of problem. But they don’t work in this case:
// @ts-expect-error: A 'const' assertions can only be applied to references to enum
// members, or string, number, boolean, array, or object literals. (1355)
= Symbol('letSymbol2') as const; let letSymbol2
LogLevel
in a functionThe following function translates members of LogLevel
to strings:
function getName(logLevel: LogLevel): string {
switch (logLevel) {
:
case off'off';
return :
case info'info';
return :
case warn'warn';
return :
case error'error';
return
}
}
.equal(
assertgetName(warn), 'warn');
How do the two approaches compare?
Recall this example where the Spanish 'no'
was confused with the English 'no'
:
type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';
: Spanish = 'no';
const spanishWord: English = spanishWord; const englishWord
If we use symbols, we don’t have this problem:
= Symbol('no');
const spanishNo = Symbol('sí');
const spanishSí type Spanish = typeof spanishNo | typeof spanishSí;
= Symbol('no');
const englishNo = Symbol('yes');
const englishYes type English = typeof englishNo | typeof englishYes;
: Spanish = spanishNo;
const spanishWord// @ts-expect-error: Type 'unique symbol' is not assignable to type 'English'. (2322)
: English = spanishNo; const englishWord
Union types 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:
Next steps:
This is a typical object-oriented 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
:
= new Addition1(
const tree 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 union type (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:
= new Addition2(
const tree new NumberValue2(1),
new Addition2(
new NumberValue2(2),
new NumberValue2(3),
,
); )
Finally, we get to discriminated unions. These are the type definitions for SyntaxTree3
:
interface NumberValue3 {: 'number-value';
kind: number;
numberValue
}
interface Addition3 {: 'addition';
kind: SyntaxTree3;
operand1: SyntaxTree3;
operand2
}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. The discriminant of SyntaxTree3
is .kind
. Its types are string literal types.
Compare:
This is an object that matches SyntaxTree3
:
: SyntaxTree3 = { // (A)
const tree: 'addition',
kind: {
operand1: 'number-value',
kind: 1,
numberValue,
}: {
operand2: 'addition',
kind: {
operand1: 'number-value',
kind: 2,
numberValue,
}: {
operand2: 'number-value',
kind: 3,
numberValue,
}
}; }
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.
In the next example, the type of tree
is a discriminated union. Every time we check its discriminant (line C), TypeScript updates its static type accordingly:
function getNumberValue(tree: SyntaxTree3) {
// %inferred-type: SyntaxTree3
; // (A)
tree
// @ts-expect-error: Property 'numberValue' does not exist on type 'SyntaxTree3'.
// Property 'numberValue' does not exist on type 'Addition3'.(2339)
.numberValue; // (B)
tree
if (tree.kind === 'number-value') { // (C)
// %inferred-type: NumberValue3
; // (D)
tree.numberValue; // OK!
return tree
};
return null }
In line A, we haven’t checked the discriminant .kind
, yet. Therefore, the current type of tree
is still SyntaxTree3
and we can’t access property .numberValue
in line B (because only one of the types of the union has this property).
In line D, TypeScript knows that .kind
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 functional 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.kind) { // (A)
'addition':
case syntaxTreeToString(tree.operand1)
return + ' + ' + syntaxTreeToString(tree.operand2);
'number-value':
case String(tree.numberValue);
return
}
}
.equal(syntaxTreeToString(tree), '1 + 2 + 3'); assert
Note that TypeScript performs exhaustiveness checking for discriminated unions: If we forget a case, TypeScript will warn us.
This is the object-oriented version of the previous code:
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 {
String(this.numberValue);
return
}
}
class Addition1 extends SyntaxTree1 {constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
super();
}toString(): string {
.operand1.toString() + ' + ' + this.operand2.toString();
return this
}
}
= new Addition1(
const tree new NumberValue1(1),
new Addition1(
new NumberValue1(2),
new NumberValue1(3),
,
);
)
.equal(tree.toString(), '1 + 2 + 3'); assert
Each approach does one kind of extensibility well:
With the object-oriented approach, 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 the functional approach, we have to modify each function if we want to add a new type. In contrast, adding new operations is simple.
Discriminated unions and normal union types 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 {: 'FileSourceFile',
type: string,
nativePath
}
interface FileSourceGenerator {: 'FileSourceGenerator',
type: 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 {: 'TemperatureCelsius',
type: number,
value
}
interface TemperatureFahrenheit {: 'TemperatureFahrenheit',
type: number,
value
}type Temperature = TemperatureCelsius | TemperatureFahrenheit;
The following pattern for implementing enums is common in JavaScript:
= {
const Color : Symbol('red'),
red: Symbol('green'),
green: Symbol('blue'),
blue; }
We can attempt to use it in TypeScript as follows:
// %inferred-type: symbol
.red; // (A)
Color
// %inferred-type: symbol
type TColor2 = // (B)
| typeof Color.red
| typeof Color.green
| typeof Color.blue
;
function toGerman(color: TColor): string {
switch (color) {
.red:
case Color'rot';
return .green:
case Color'grün';
return .blue:
case Color'blau';
return default:
// No exhaustiveness check (inferred type is not `never`):
// %inferred-type: symbol
;
color
// Prevent static error for return type:
new Error();
throw
} }
Alas, the type of each property of Color
is symbol
(line A) and TColor
(line B) is an alias for symbol
. As a consequence, we can pass any symbol to toGerman()
and TypeScript won’t complain at compile time:
.equal(
asserttoGerman(Color.green), 'grün');
.throws(
assert=> toGerman(Symbol())); // no static error! ()
A const
assertion often helps in this kind of situation but not this time:
= {
const ConstColor : Symbol('red'),
red: Symbol('green'),
green: Symbol('blue'),
blueas const;
}
// %inferred-type: symbol
.red; ConstColor
The only way to fix this is via constants:
= Symbol('red');
const red = Symbol('green');
const green = Symbol('blue');
const blue
// %inferred-type: unique symbol
;
red
// %inferred-type: unique symbol | unique symbol | unique symbol
type TColor2 = typeof red | typeof green | typeof blue;
= {
const Color : 'red',
red: 'green',
green: 'blue',
blueas const; // (A)
}
// %inferred-type: "red"
.red;
Color
// %inferred-type: "red" | "green" | "blue"
type TColor =
| typeof Color.red
| typeof Color.green
| typeof Color.blue
;
We need as const
in line A so that the properties of Color
don’t have the more general type string
. Then TColor
also has a type that is more specific than string
.
Compared to using an object with symbol-valued properties as an enum, string-valued properties are:
Upsides:
Downsides:
The following example demonstrates a Java-inspired enum pattern that works in plain JavaScript and TypeScript:
class Color {= new Color();
static red = new Color();
static green = new Color();
static blue
}
// @ts-expect-error: Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman(color: Color): string { // (A)
switch (color) {
.red:
case Color'rot';
return .green:
case Color'grün';
return .blue:
case Color'blau';
return
}
}
.equal(toGerman(Color.blue), 'blau'); assert
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.