In this blog post, we take a closer look at TypeScript enums:
The blog post concludes with recommendations for what to use when.
For showing inferred types in the source code, I use the npm package ts-expect
– e.g.:
// Types of values
expectType<string>('abc');
expectType<number>(123);
// Equality of types
type Pair<T> = [T, T];
expectType<TypeEqual<Pair<string>, [string,string]>>(true);
There are many different kinds of enums in various programming languages. In TypeScript, an enum defines two things:
Note that we are ignoring const enums in this blog post.
Next, we’ll look at various aspects of enums in more detail.
On one hand, an enum is an object that maps member keys to member values. In that way, it works much like an object literal:
enum Color {
Red = 0,
Green = 'GREEN',
}
assert.equal(Color.Red, 0);
assert.equal(Color.Green, 'GREEN');
assert.equal(Color['Green'], 'GREEN');
One limitation is that only numbers and strings are allowed as member values.
On the other hand, an enum is also a type that only contains the member values:
let color: Color;
color = Color.Red;
color = Color.Green;
// @ts-expect-error: Type 'true' is not assignable to type 'Color'.
color = true;
There is one important difference between string members and number members: We cannot assign plain strings to color
:
// @ts-expect-error: Type '"GREEN"' is not assignable to
// type 'Color'.
color = 'GREEN';
But we can assign a plain number to color
– if it is the value of a member:
color = 0;
// @ts-expect-error: Type '123' is not assignable to type 'Color'.
color = 123;
Consider the following enum:
enum Color {
Red = 0,
Green = 'GREEN',
}
If we handle the values that a variable of type Color
may have, then TypeScript can warn us if we forget one of them. In other words: If we didn’t handle all cases “exhaustively”. That is called an exhaustiveness check. To see how that works, let’s start with the following code:
// @ts-expect-error: Not all code paths return a value.
function colorToString(color: Color) {
expectType<Color>(color); // (A)
if (color === Color.Red) {
return 'red';
}
expectType<Color.Green>(color); // (B)
if (color === Color.Green) {
return 'green';
}
expectType<never>(color); // (C)
}
In line A, color
can still have any value. In line B, we have crossed off Color.Red
and color
can only have the value Color.Green
. In line C, color
can’t have any value – which explains its type never
.
If color
is not never
in line C then we have forgotten a member. We can let TypeScript report an error at compile time like this:
function colorToString(color: Color) {
if (color === Color.Red) {
return 'red';
}
if (color === Color.Green) {
return 'green';
}
throw new UnsupportedValueError(color);
}
How does that work? The value
we pass to UnsupportedValueError
must have the type never
:
class UnsupportedValueError extends Error {
constructor(value: never, message = `Unsupported value: ${value}`) {
super(message)
}
}
This is what happens if we forget the second case:
function colorToString(color: Color) {
if (color === Color.Red) {
return 'red';
}
// @ts-expect-error: Argument of type 'Color.Green'
// is not assignable to parameter of type 'never'.
throw new UnsupportedValueError(color);
}
Exhaustiveness checking works just as well with case
statements:
function colorToString(color: Color) {
switch (color) {
case Color.Red:
return 'red';
case Color.Green:
return 'green';
default:
throw new UnsupportedValueError(color);
}
}
Another way to check exhaustiveness is by specifying a return type for the function:
// @ts-expect-error: Function lacks ending return statement and
// return type does not include 'undefined'.
function colorToString(color: Color): string {
switch (color) {
case Color.Red:
return 'red';
}
}
In my code, I usually do that but additionally throw an UnsupportedValueError
because I like having a check that also works at runtime.
One operation that is occasionally useful is enumerating the members of an enum. Can we do that with TypeScript enums? Let’s use our previous enum:
enum Color {
Red = 0,
Green = 'GREEN',
}
Compiled to JavaScript, Color
looks like this:
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red"; // (A)
Color["Green"] = "GREEN"; // (B)
})(Color || (Color = {}));
This is a function that is immediately invoked and adds properties to an object Color
.
The code for the string member Green
in line B is straightforward: It maps from key to value.
The code for the number member Red
in line A adds two properties for Red
instead of one – a mapping from key to value and a mapping from value to key:
Color["Red"] = 0;
Color[0] = "Red";
Note that the zero in the second line is coerced to a string (a property key can only be a string or a symbol). Thus, we can’t even really look up a number this way. We have to convert it to a string first.
Therefore, the number member prevents us from enumerating the keys or values of this enum:
assert.deepEqual(
Object.keys(Color), ['0', 'Red', 'Green']
);
assert.deepEqual(
Object.values(Color), ['Red', 0, 'GREEN']
);
If we switch to only string members then enumeration works:
enum Color {
Red = 'RED',
Green = 'GREEN',
}
assert.deepEqual(
Object.keys(Color), ['Red', 'Green']
);
assert.deepEqual(
Object.values(Color), ['RED', 'GREEN']
);
We can also create enums without explicitly specifying member values. Then TypeScript specifies them for us and uses numbers:
enum Color {
Red, // implicitly = 0
Green, // implicitly = 1
}
assert.equal(Color.Red, 0);
assert.equal(Color.Green, 1);
Making sense of enums and enum-related patterns can quickly get confusing because what an enum is varies widely between programming languages – e.g.:
Therefore, we benefit from a narrow definition of the term enum:
In the following sections, we’ll go through the following use cases for this kind of enum:
We’ll consider how well TypeScript enums work for these use cases. (Spoiler: they work reasonably well for #1 and #2 but can’t be used for #3.) And we’ll look at enum-like patterns that we can use instead.
For each option, we’ll also examine:
One way in which enums (or enum-like objects) are sometimes used is simply as a namespace for constants – e.g., the Node.js function fs.access()
has a parameter mode
whose values are provided via an object that is similar to the following enum:
enum constants {
F_OK = 0,
R_OK = 4,
W_OK = 2,
X_OK = 1,
// ...
}
Except for the first value, these are bits that are combined via bitwise Or:
const readOrWrite = constants.R_OK | constants.W_OK;
Which enum features are relevant for this use case?
mode
is number
, not constants
or something similar. That’s because the values of constants
are not an exhaustive list of all possible values of mode
.For this use case, an object literal is a very good alternative:
const constants = {
__proto__: null,
F_OK: 0,
R_OK: 4,
W_OK: 2,
X_OK: 1,
};
We use the pseudo property key __proto__
to set the prototype of constants
to null
. That is a good practice because then we don’t have to deal with inherited properties:
in
operator to check if constants
has a given key without worrying about properties inherited from Object.prototype
such as .toString
.Object.keys()
and Object.values()
ignore inherited properties anyway, so we don’t gain anything there.Note that __proto__
also exists as a getter and a setter in Object.prototype
. This feature is deprecated in favor of Object.getPrototypeOf()
and Object.setPrototypeOf()
. However, that is different from using this name in an object literal – which is not deprecated.
For more information, check out these sections of “Exploring JavaScript”:
Object.prototype.__proto__
(accessor)”Sometimes we may want to define our own custom type that has a fixed set of values. For example, booleans don’t always express intentions well. Then an enum can do a better job:
enum Activation {
Active = 'Active',
Inactive = 'Inactive',
}
It’s a good practice to explicitly specify string values via =
:
Activation
is expected.Activation
.Which enum features are relevant for this use case?
Activation
.Let’s use an object literal to define the value part of an enum (we’ll get to the type part next):
const Activation = {
__proto__: null,
Active: 'Active',
Inactive: 'Inactive',
} as const; // (A)
// Without `as const`, this type would be `string`:
expectType<'Active'>(Activation.Active);
type ActivationType = PropertyValues<typeof Activation>;
expectType<
TypeEqual<ActivationType, 'Active' | 'Inactive'>
>(true);
The as const
in line A enables us to derive ActivationType
from Activation
via the helper type PropertyValues
(which is defined below).
Why is this type called ActivationType
and not Activation
? Since the namespaces of values and types are separate in TypeScript, we could indeed use the same name. However, I’ve had issues when using Visual Studio Code to rename value and type: It got confused because importing Activation
imported both value and type. That’s why I’m using different names – for now.
The helper type PropertyValues
looks like this:
type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];
Obj[K]
contains the values of all properties whose keys are in K
.'__proto__'
from keyof Obj
because TypeScript treats that key as a normal property and that’s not what we want (related GitHub issue).Let’s explore what the derived type looks like if we don’t use as const
:
const Activation = {
__proto__: null,
Active: 'Active',
Inactive: 'Inactive',
};
expectType<string>(Activation.Active);
expectType<string>(Activation.Inactive);
type ActivationType = PropertyValues<typeof Activation>;
expectType<
TypeEqual<ActivationType, string>
>(true);
TypeScript supports exhaustiveness checks for unions of literal types. And that’s what ActivationType
is. Therefore, we can use the same pattern as we did with enums:
function activationToString(activation: ActivationType): string {
switch (activation) {
case Activation.Active:
return 'ACTIVE';
case Activation.Inactive:
return 'INACTIVE';
default:
throw new UnsupportedValueError(activation);
}
}
We can use Object.keys()
and Object.values()
to enumerate the members of the object Activation
:
for (const value of Object.values(Activation)) {
console.log(value);
}
Output:
Active
Inactive
One downside of using strings as property values is that ActivationType
does not exclude arbitrary strings from being used. We can get more type safety if we use symbols:
const Active = Symbol('Active');
const Inactive = Symbol('Inactive');
const Activation = {
__proto__: null,
Active,
Inactive,
} as const;
expectType<typeof Active>(Activation.Active);
type ActivationType = PropertyValues<typeof Activation>;
expectType<
TypeEqual<
ActivationType, typeof Active | typeof Inactive
>
>(true);
This seems overly complicated: Why the intermediate step of first declaring variables for the symbols before we use them? Why not create the symbols inside the object literal? Alas, that’s a current limitation of as const
for symbols: They are not recognized as unique (related GitHub issue):
const Activation = {
__proto__: null,
Active: Symbol('Active'),
Inactive: Symbol('Inactive'),
} as const;
// Alas, the type of Activation.Active is not `typeof Active`
expectType<symbol>(Activation.Active);
type ActivationType = PropertyValues<typeof Activation>;
expectType<
TypeEqual<ActivationType, symbol>
>(true);
A union of string literal types is an interesting alternative to an enum when it comes to defining a type with a fixed set of members:
type Activation = 'Active' | 'Inactive';
How does such a type compare to an enum?
Pros:
Cons:
Reification means creating an entity at the object level (think JavaScript values) for an entity that exists at the meta level (think TypeScript types).
We can use a Set to reify a string literal union type:
const activation = new Set([
'Active',
'Inactive',
] as const);
expectType<Set<'Active' | 'Inactive'>>(activation);
// @ts-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type '"Active" | "Inactive"'.
activation.has('abc');
// Auto-completion works for arguments of .has(), .delete() etc.
// Let’s turn the Set into a string literal union
type Activation = SetElementType<typeof activation>;
expectType<
TypeEqual<Activation, 'Active' | 'Inactive'>
>(true);
type SetElementType<S extends Set<any>> =
S extends Set<infer Elem> ? Elem : never;
Sometimes, it’s useful to have an enum-like construct for looking up richer data – stored in objects. We can’t use objects as enum values, so we’ll have to use other solutions.
This is an example of using an object literal as an enum for objects:
// This type is optional: It constrains the property values
// of `TextStyle` but has no other use.
type TTextStyle = {
key: string,
html: string,
latex: string,
};
const TextStyle = {
Bold: {
key: 'Bold',
html: 'b',
latex: 'textbf',
},
Italics: {
key: 'Italics',
html: 'i',
latex: 'textit',
},
} as const satisfies Record<string, TTextStyle>;
type TextStyleType = PropertyValues<typeof TextStyle>;
type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];
Why do the property values of TextStyle
have the property .key
? That property lets us do exhaustiveness checks because the property values form a discriminated union.
function f(textStyle: TextStyleType): string {
switch (textStyle.key) {
case TextStyle.Bold.key:
return 'BOLD';
case TextStyle.Italics.key:
return 'ITALICS';
default:
throw new UnsupportedValueError(textStyle); // No `.key`!
}
}
For comparison, this is what f()
would look like if TextStyle
were an enum:
enum TextStyle2 { Bold, Italics }
function f2(textStyle: TextStyle2): string {
switch (textStyle) {
case TextStyle2.Bold:
return 'BOLD';
case TextStyle2.Italics:
return 'ITALICS';
default:
throw new UnsupportedValueError(textStyle);
}
}
We can also use a class as an enum – a pattern that is borrowed from Java:
class TextStyle {
static Bold = new TextStyle(/*...*/);
static Italics = new TextStyle(/*...*/);
}
type TextStyleKeys = EnumKeys<typeof TextStyle>;
expectType<
TypeEqual<TextStyleKeys, 'Bold' | 'Italics'>
>(true);
type EnumKeys<T> = Exclude<keyof T, 'prototype'>;
One pro of this pattern is that we can use methods to add behavior to the enum values. A con is that there is no simple way to get an exhaustiveness check.
Object.keys()
and Object.values()
ignore non-enumerable properties of TextStyle
such as .prototype
– which is why we can use them to enumerate keys and values – e.g.:
assert.deepEqual(
// TextStyle.prototype is non-enumerable
Object.keys(TextStyle),
['Bold', 'Italics']
);
Sometimes we want to translate enum values to other values or vice versa – e.g. when serializing them to JSON or deserializing them from JSON. If we do so via a Map
, we can use TypeScript to get a warning if we forget an enum value.
To explore how that works, we’ll use the following enum pattern type:
const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');
const TaskStatus = {
__proto__: null,
Pending,
Ongoing,
Finished,
} as const;
type TaskStatusType = PropertyValues<typeof TaskStatus>;
type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];
This is the Map:
const taskPairs = [
[TaskStatus.Pending, 'not yet'],
[TaskStatus.Ongoing, 'working on it'],
[TaskStatus.Finished, 'finished'],
] as const;
type Key = (typeof taskPairs)[number][0];
const taskMap = new Map<Key, string>(taskPairs);
If you are wondering why we didn’t directly use the value of taskPairs
as the argument of new Map()
and omit the type parameters: TypeScript doesn’t seem to be able to infer the type parameters if the keys are symbols and reports a compile-time error. With strings, the code would be simpler:
const taskPairs = [
['Pending', 'not yet'],
['Ongoing', 'working on it'],
['Finished', 'finished'],
] as const;
const taskMap = new Map(taskPairs); // no type parameters!
The final step is to check if we forgot only of the values of TaskStatus
:
expectType<
TypeEqual<MapKey<typeof taskMap>, TaskStatusType>
>(true);
type MapKey<M extends Map<any, any>> =
M extends Map<infer K, any> ? K : never;
When should we use an enum and when an alternative pattern?
TypeScript enums are not JavaScript: Enums are one of the few TypeScript language constructs (vs. type constructs) that have no corresponding JavaScript features. That can matter in two ways:
Performance of strings: One thing to keep in mind is that comparing strings is usually slower than comparing numbers or symbols. Therefore, enums or enum patterns where values are strings, are slower. Note that applies to string literal unions too. But such a performance cost only matters if we do many comparisons.
What is the use case? Looking at the use cases can help us make a decision: