TypeScript has built-in support for enums which are basically namespaces for constants. In this chapter we explore how they work, what patterns we can use instead and how to choose between them.
For TypeScript, it has become desirable to only use JavaScript at the non-type level because that makes compilation easier to understand and faster (thanks to a technique called type stripping). That coding style can be enforced via the compiler option erasableSyntaxOnly
).
Enums are one of the few non-type features that are not JavaScript. That’s why, after examining how they work, we’ll also look into JavaScript alternatives: patterns that we can use instead of enums. In order to find them, we’ll focus on three use cases.
This use case is about grouping related constants – think lookup table – e.g.:
const Color_Red = '#FF0000';
const Color_Green = '#00FF00';
const Color_Blue = '#0000FF';
We’d like to have a namespace for these constants and access them via Color.Red
etc.
Sometimes, we want to define a custom type that has a limited set of values – e.g. to express program states:
const Status_Pending = 'Pending';
const Status_Ongoing = 'Ongoing';
const Status_Finished = 'Finished';
type Status =
| typeof Status_Pending
| typeof Status_Ongoing
| typeof Status_Finished
;
For this use case, we want to be able to check exhaustiveness: When we handle cases (e.g. via switch
), TypeScript should warn us during type checking if we forget a case:
function describeStatus(status: Status): string {
switch (status) {
case Status_Pending:
return 'Not yet';
case Status_Ongoing:
return 'Working on it...';
case Status_Finished:
return 'We are done';
default:
throw new UnexpectedValueError(status);
}
}
This is a simple version of UnexpectedValueError
:
class UnexpectedValueError extends Error {
constructor(value: never) {
// Only solution that can stringify undefined, null, symbols, and
// objects without prototypes
super('Unexpected value: ' + {}.toString.call(value));
}
}
How does it work? TypeScript infers the type never
if there are no more values a variable can have. So that’s the type of status
after switch
checked all values it can have. The constructor UnexpectedValueError
produces a type error if its parameter does not have the type never
. For more information and a more sophisticated version of UnexpectedValueError
, see “Exhaustiveness checks” (§26.4.3.3).
This is an example of such constants:
const TextStyle_Bold = {
key: 'Bold',
html: 'b',
latex: 'textbf',
};
const TextStyle_Italics = {
key: 'Italics',
html: 'i',
latex: 'textit',
};
Interestingly, this use case expands both use case 1 and use case 2:
On one hand, we can look up more values.
On the other hand, we can define a type for the constants and then get type members with more information:
type TextStyle = typeof TextStyle_Bold | typeof TextStyle_Italics;
TypeScript enums do not support this use case. But there are enum patterns that do.
This is a numeric enum:
enum NoYes {
No = 0,
Yes = 1, // trailing comma
}
assert.equal(NoYes.No, 0);
assert.equal(NoYes.Yes, 1);
This enum looks and works similarly to an object literal:
It has the members No
and Yes
.
Each member has a name and (in this case) is explicitly assigned a value via an initializer: an equal sign followed by a value. We access those values via the dot operator:
assert.equal(NoYes.No, 0);
assert.equal(NoYes.Yes, 1);
As in object literals, trailing commas are allowed and ignored.
This is how NoYes
is used:
function toGerman(value: NoYes) {
switch (value) {
case NoYes.No:
return 'Nein';
case NoYes.Yes:
return 'Ja';
}
}
assert.equal(toGerman(NoYes.No), 'Nein');
assert.equal(toGerman(NoYes.Yes), 'Ja');
Instead of numbers, we can also use strings as enum member values:
enum NoYes {
No = 'No',
Yes = 'Yes',
}
assert.equal(NoYes.No, 'No');
assert.equal(NoYes.Yes, 'Yes');
The last kind of enum is called heterogeneous. The member values of a heterogeneous enum are a mix of numbers and strings:
enum Enum {
One = 'One',
Two = 'Two',
Three = 3,
Four = 4,
}
assert.deepEqual(
[Enum.One, Enum.Two, Enum.Three, Enum.Four],
['One', 'Two', 3, 4]
);
Heterogeneous enums are not used often because they have few applications.
Alas, TypeScript only supports numbers and strings as enum member values. Other values, such as symbols, are not allowed.
We can also completely omit initializers – in which case TypeScript automatically assigns numbers:
enum NoYes {
No,
Yes,
}
assert.equal(NoYes.No, 0);
assert.equal(NoYes.Yes, 1);
It’s also possible to only omit some of the initializers. However, my recommendation is to avoid that and either omit none or all. We won’t go into the details of how exactly partial omissions work, but this is a quick example:
enum Omissions {
A,
B,
C = 'C',
D = 'D',
E = 8,
// We can only omit the initializer
// at the beginning or after a number
F,
}
assert.equal(Omissions.A, 0);
assert.equal(Omissions.B, 1);
assert.equal(Omissions.C, 'C');
assert.equal(Omissions.D, 'D');
assert.equal(Omissions.E, 8);
assert.equal(Omissions.F, 9);
Similar to JavaScript objects, we can quote the names of enum members:
enum HttpRequestField {
'Accept',
'Accept-Charset',
'Accept-Datetime',
'Accept-Encoding',
'Accept-Language',
}
assert.equal(HttpRequestField['Accept-Charset'], 1);
There is no way to compute the names of enum members. Object literals support computed property keys via square brackets.
There are several precedents for naming constants (in enums or elsewhere):
Number.MAX_VALUE
Math.SQRT2
Symbol.asyncIterator
NoYes
enum.
Consider the following enum:
enum ShapeKind { Circle, Rectangle }
ShapeKind
as a valueOn one hand, ShapeKind
is a value:
const value = ShapeKind.Circle;
assert.equal(value, 0);
ShapeKind
as a typeOn the other hand, ShapeKind
is also a type – whose structure is quite different from its value:
type _ = [
// Type `ShapeKind` is assignable to and from
// a union of number literal types
Assert<Assignable<
0 | 1, ShapeKind
>>,
Assert<Assignable<
ShapeKind, 0 | 1
>>,
// Accordingly, the keys of type `ShapeKind` are
// equal to the keys of type `number`
Assert<Equal<
keyof ShapeKind,
keyof number
>>,
];
In other words: Even though the value of an enum is similar to an object, its type is quite different from an object type (whose keys are the types of the property keys, etc.).
ShapeKind
as a namespace for enum member typesEven though ShapeKind
is basically a union type, it is additionally a namespace for the types of its members – e.g., the type ShapeKind.Circle
is assignable to and from the literal number type 0
:
type _ = [
Assert<Assignable<
0, ShapeKind.Circle
>>,
Assert<Assignable<
ShapeKind.Circle, 0
>>,
];
That means we can use enum member types at the type level – e.g. for parameters:
function describeCircle(circle: ShapeKind.Circle): string {
// ···
}
Or for discriminants in discriminated unions:
type Shape =
| {
kind: ShapeKind.Circle,
center: Point,
}
| {
kind: ShapeKind.Rectangle,
corner1: Point,
corner2: Point,
}
;
type Point = {
x: number,
y: number,
}
If a parameter has a number enum type, it accepts both enum member values and numbers:
enum Fruit { Apple, Strawberry }
function check(_animal: Fruit) {}
check(Fruit.Apple);
check(0);
However, we can only use numbers that are values of enum members:
// @ts-expect-error: Argument of type '2' is not assignable to parameter of type 'Fruit'.
check(2);
If a parameter has a string enum type, member values are considered to be unique – it does not accept strings:
enum Fruit { Apple='Apple', Strawberry='Strawberry' }
function check(_animal: Fruit) {}
check(Fruit.Apple);
// @ts-expect-error: Argument of type '"Apple"' is not assignable to parameter of type 'Fruit'.
check('Apple');
TypeScript compiles enums to JavaScript objects.
As an example, take the following enum:
enum Animal { Dog, Cat }
TypeScript compiles this enum to:
var Animal;
(function (Animal) {
Animal[Animal["Dog"] = 0] = "Dog";
Animal[Animal["Cat"] = 1] = "Cat";
})(Animal || (Animal = {}));
In this code, the following assignments are made:
Animal["Dog"] = 0;
Animal["Cat"] = 1;
Animal[0] = "Dog";
Animal[1] = "Cat";
There are two groups of assignments:
TypeScript lets us use the reverse mappings to look up an enum key, given an enum value:
enum Animal { Dog, Cat }
assert.equal(
Animal[0], 'Dog'
);
String-based enums have a simpler representation at runtime.
Consider the following enum.
enum Animal {
Dog = 'DOG!',
Cat = 'CAT!',
}
It is compiled to this JavaScript code:
var Animal;
(function (Animal) {
Animal["Dog"] = "DOG!";
Animal["Cat"] = "CAT!";
})(Animal || (Animal = {}));
TypeScript does not support reverse mappings for string-based enums.
Let’s get back to the use cases mentioned at the beginning of this chapter.
We can use an enum as a namespace for constants with primitive values:
enum Color {
Red = '#FF0000',
Green = '#00FF00',
Blue = '#0000FF',
}
Enums work well for this use case – but their values can only be numbers or strings.
The following enum defines a custom type with unique values:
enum Status {
Pending = 'Pending',
Ongoing = 'Ongoing',
Finished = 'Finished',
}
Benefit of explicitly specify string values via =
: We get more type safety and can’t accidentally use string equal to enum values where a Status
is expected.
Note that we don’t need to explicitly define a type – Status
is already a type.
Exhaustiveness checks for enums are supported:
function describeStatus(status: Status): string {
switch (status) {
case Status.Pending:
return 'Not yet';
case Status.Ongoing:
return 'Working on it...';
case Status.Finished:
return 'We are done';
default:
throw new UnexpectedValueError(status);
}
}
assert.equal(
describeStatus(Status.Pending),
'Not yet'
);
If an enum is prefixed with the keyword const
, it doesn’t have a representation at runtime. Instead, the values of its member are used directly.
Consider the following const enum:
const enum Vegetable {
Carrot = 'Carrot',
Onion = 'Onion',
}
function toGerman(vegetable: Vegetable) {
switch (vegetable) {
case Vegetable.Carrot:
return 'Karotte';
case Vegetable.Onion:
return 'Zwiebel';
}
}
If we compile it, the const enum is not represented at runtime. Only the values of its members remain:
function toGerman(vegetable) {
switch (vegetable) {
case "Carrot" /* Vegetable.Carrot */:
return 'Karotte';
case "Onion" /* Vegetable.Onion */:
return 'Zwiebel';
}
}
Compare that to the compilation output of a normal enum Vegetable
:
var Vegetable;
(function (Vegetable) {
Vegetable["Carrot"] = "Carrot";
Vegetable["Onion"] = "Onion";
})(Vegetable || (Vegetable = {}));
function toGerman(vegetable) {
switch (vegetable) {
case Vegetable.Carrot:
return 'Karotte';
case Vegetable.Onion:
return 'Zwiebel';
}
}
For most projects, it is better to avoid const enums:
erasableSyntaxOnly
is active.
isolatedModules
.
The TypeScript handbook describes these and other pitfalls in more detail.
It’s time to look at plain JavaScript patterns that we can use instead of enums. One common pattern is to define “enum objects” via object literals:
const Tree = {
Maple: 'MAPLE',
Oak: 'OAK',
};
We have just seen the basic form of enum objects. There are several ways in which we can improve that pattern:
{} as const
Object.freeze({})
null
prototype via {__proto__: null}
#1 is required if we want to derive a type from an enum object. The other two improvements are optional. They produce better results but also add visual clutter.
as const
object literal: deriving a type for enum values and keysIf we apply as const
to an object literal, we get more specific property values at the type level:
const Tree = {
Maple: 'MAPLE',
Oak: 'OAK',
};
type _1 = Assert<Equal<
typeof Tree,
{
Maple: string,
Oak: string,
}
>>;
const TreeAsConst = {
Maple: 'MAPLE',
Oak: 'OAK',
} as const;
type _2 = Assert<Equal<
typeof TreeAsConst,
{
readonly Maple: 'MAPLE',
readonly Oak: 'OAK',
}
>>;
Deriving a type for the property values. How is that useful? It enables us to create a type for the property values of Tree
:
type _ = [
Assert<Equal<
ValueOf<typeof Tree>,
string
>>,
Assert<Equal<
ValueOf<typeof TreeAsConst>,
'MAPLE' | 'OAK'
>>,
];
Being able to create a type for enum object values will help us with the use case “custom type with unique values”.
The helper type ValueOf
looks like this:
type ValueOf<Obj> = Obj[keyof Obj];
The indexed access type Obj[K]
contains the values of all properties whose keys are in K
.
Deriving a type for the property keys. We can also derive a type for the keys of Tree
. But for that, we don’t need as const
:
type _3 = Assert<Equal<
keyof (typeof Tree),
'Maple' | 'Oak'
>>;
type _4 = Assert<Equal<
keyof (typeof TreeAsConst),
'Maple' | 'Oak'
>>;
At the JavaScript level, we can freeze objects:
const TreeFrozen = Object.freeze({
Maple: 'MAPLE',
Oak: 'OAK',
});
That helps us at runtime because TreeFrozen
can’t be changed:
assert.throws(
() => TreeFrozen.newProp = true,
/^TypeError: Cannot add property newProp, object is not extensible$/
);
assert.throws(
() => TreeFrozen.Maple = 'Ash',
/^TypeError: Cannot assign to read only property 'Maple'/
);
At the type level, Object.freeze()
has the same effect as as const
:
const frozenObject = Object.freeze({
prop: 123
});
type _ = Assert<Equal<
typeof frozenObject,
{
readonly prop: 123,
}
>>;
null
prototype: no inherited propertiesWe can 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:
Consider the following two versions of Tree
:
const Tree = {
Maple: 'MAPLE',
Oak: 'OAK',
};
const TreeProtoNull = {
__proto__: null,
Maple: 'MAPLE',
Oak: 'OAK',
};
We can’t use the in
operator to check if the enum Tree
has a given key because it also considers inherited properties – which are not enum members:
assert.equal(
'toString' in Tree,
true
);
assert.equal(
'toString' in TreeProtoNull,
false
);
We can also read inherited properties and that looks like as if the enum Tree
has more members than it does:
assert.equal(
typeof Tree.toString,
'function'
);
assert.equal(
TreeProtoNull.toString,
undefined
);
However, Object.keys()
and Object.values()
are safe to use – they only consider own (non-inherited) properties:
assert.deepEqual(
Object.keys(Tree),
['Maple', 'Oak']
);
assert.deepEqual(
Object.keys(TreeProtoNull),
['Maple', 'Oak']
);
assert.deepEqual(
Object.values(Tree),
['MAPLE', 'OAK']
);
assert.deepEqual(
Object.values(TreeProtoNull),
['MAPLE', 'OAK']
);
__proto__
deprecated?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)”
{__proto__}
: additional property at type levelIf an object literal uses .__proto__
then TypeScript includes that property at the type level:
const TreeProtoNull = {
__proto__: null,
Maple: 'MAPLE',
Oak: 'OAK',
} as const;
type _ = Assert<Equal<
typeof TreeProtoNull,
{
readonly __proto__: null;
readonly Maple: 'MAPLE';
readonly Oak: 'OAK';
}
>>;
I’d prefer that weren’t the case – given that .__proto__
is not a real property (related GitHub issue).
As a consequence, we have to manually exclude the key '__proto__'
when deriving a type from TreeProtoNull
via ValueOf
(line A):
type TreeProtoNullType = ValueOf<typeof TreeProtoNull>;
// 123 | null
type _Y = [
Assert<Equal<
ValueOf<typeof TreeProtoNull>,
'MAPLE' | 'OAK' | null
>>,
Assert<Equal<
ValueOf<Omit<typeof TreeProtoNull, '__proto__'>>, // (A)
'MAPLE' | 'OAK'
>>,
];
Another workaround is to create a helper function for creating enums that sets the prototype to null at runtime but doesn’t use expose .__proto__
at the type level. We’ll do that next.
This is what the enum object pattern looks like if we use all of the improvements mentioned in the previous subsections:
const Tree = Object.freeze({
__proto__: null,
Maple: 'MAPLE',
Oak: 'OAK',
});
Note that we don’t need as const
because Object.freeze()
has the same effect.
A helper function can make this code slightly less verbose. The result has a type with specific property value types, from which we can derive a type for Tree
:
const Tree = createEnum({
Maple: 'MAPLE',
Oak: 'OAK',
});
type _ = Assert<Equal<
typeof Tree,
{
readonly Maple: 'MAPLE';
readonly Oak: 'OAK';
}
>>;
This is the helper function:
/**
* Returns an enum object. Adds the following improvements:
* - Sets the prototype to `null`.
* - Freezes the object.
* - The result has the same type as if `as const` had been applied.
*/
function createEnum<
// The two type variables are necessary so that the result has specific
// property value types.
T extends { [idx: string]: V },
V extends
| undefined | null | boolean | number | bigint | string | symbol
| object
>(enumObj: T): Readonly<T> {
// Copying `enumObj` is better for performance than Object.setPrototypeOf()
return Object.freeze({
__proto__: null,
...enumObj,
});
}
For this use case, an object literal is a very good alternative:
const Color = {
Red: '#FF0000',
Green: '#00FF00',
Blue: '#0000FF',
};
We can use a null
prototype and freezing but they are not required in this case.
Let’s use an object literal to define the value part of an enum (we’ll get to the type part next):
const Status = {
Pending: 'Pending',
Ongoing: 'Ongoing',
Finished: 'Finished',
} as const; // (A)
Thanks to as const
in line A, we can derive a type from Status
:
type StatusType = ValueOf<typeof Status>;
type _ = Assert<Equal<
StatusType, 'Pending' | 'Ongoing' | 'Finished'
>>;
The utility type ValueOf
was defined previously.
Why is this type called StatusType
and not Status
? 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: You can’t do both at the same time and VSC gets confused because importing Status
imports both value and type.
A benefit of using the name StatusType
and not TStatus
is that the former shows up in auto-completions for Status
.
TypeScript supports exhaustiveness checks for unions of literal types. And that’s what StatusType
is. Therefore, we can use the same pattern as we did with enums:
function describeStatus(status: StatusType): string { // (A)
switch (status) {
case Status.Pending:
return 'Not yet';
case Status.Ongoing:
return 'Working on it...';
case Status.Finished:
return 'We are done';
default:
throw new UnexpectedValueError(status);
}
}
assert.equal(
describeStatus(Status.Pending),
'Not yet'
);
Note that in line A, the type of status
is StatusType
, not Status
(which is a value).
An enum defines a value, a type and types for members. For enum objects, we have to create the latter two manually. We have already derived a type from an enum object. How about the types for members? Consider the following enum object:
const ShapeKind = {
Circle: 0,
Rectangle: 1,
} as const;
The members ShapeKind.Circle
and ShapeKind.Rectangle
only exist as values, not as types:
// @ts-expect-error: Cannot find namespace 'ShapeKind'.
type _ = ShapeKind.Circle;
Therefore, if we want to use those values at the type level, we have to apply typeof
to them (line A and line B):
type Shape =
| {
key: typeof ShapeKind.Circle, // (A)
center: Point,
}
| {
key: typeof ShapeKind.Rectangle, // (B)
corner1: Point,
corner2: Point,
}
;
type Point = {
x: number,
y: number,
}
One downside of using strings as the property values of an enum object is that they are not unique: A derived type accepts both the property values and strings created via string literals (if they are equal to them). We can get more type safety if we use symbols:
const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');
const Status = {
Pending,
Ongoing,
Finished,
} as const;
assertType<typeof Pending>(Status.Pending);
type StatusType = ValueOf<typeof Status>;
type _ = Assert<Equal<
StatusType,
typeof Pending | typeof Ongoing | typeof Finished
>>;
The utility type ValueOf
was defined previously.
This seems overly complicated: Why the intermediate step of first declaring variables for the symbols before using them? Why not create the symbols inside the object literal? Alas, that’s a current limitation of symbols in as const
objects – they don’t produce unique types (related GitHub issue):
const Status = {
Pending: Symbol('Pending'),
Ongoing: Symbol('Ongoing'),
Finished: Symbol('Finished'),
} as const;
// Alas, the type of Status.Pending is not `typeof Pending`
assertType<symbol>(Status.Pending);
// The derived type is `symbol`, not a union type
type StatusType = ValueOf<typeof Status>;
type _ = Assert<Equal<
StatusType,
symbol
>>;
Sometimes, it’s useful to have an enum-like construct for looking up richer data – stored in objects. That’s something enums can’t do, but it is possible via enum objects:
// This type is optional: It constrains the property values
// of `TextStyle` but has no other use.
type TextStyleProp = {
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, TextStyleProp>;
type TextStyleType = ValueOf<typeof TextStyle>;
type ValueOf<Obj> = Obj[keyof Obj];
Because each property value of TextStyle
has the property .key
with a unique value, TextStyleType
is a discriminated union.
Due to TextStyleType
being a discriminated union, we can do an exhaustiveness check:
function describeTextStyle(textStyle: TextStyleType): string {
switch (textStyle.key) {
case TextStyle.Bold.key:
return 'Bold text';
case TextStyle.Italics.key:
return 'Text in italics';
default:
throw new UnexpectedValueError(textStyle); // No `.key`!
}
}
In the default
case, after we have checked all values that textStyle.key
can have, textStyle
itself has the type never
.
We can also use a class as an enum – a pattern that is borrowed from Java:
class TextStyle {
static Bold = new TextStyle({
html: 'b',
latex: 'textbf',
});
static Italics = new TextStyle({
html: 'i',
latex: 'textit',
});
html: string;
latex: string;
constructor(props: TextStyleProps) {
this.html = props.html;
this.latex = props.latex;
}
wrapHtml(html: string): string {
return `<${this.html}>${html}</${this.html}>`;
}
}
type TextStyleProps = {
html: string,
latex: string,
};
assert.equal(
TextStyle.Bold.wrapHtml('Hello!'),
'<b>Hello!</b>'
);
We can create a type with the static properties of TextStyle
:
type TextStyleKeys = EnumKeys<typeof TextStyle>;
type _1 = Assert<Equal<
TextStyleKeys, 'Bold' | 'Italics'
>>;
type EnumKeys<T> = Exclude<keyof T, 'prototype'>;
// Why exclude 'prototype'?
type _2 = Assert<Equal<
keyof typeof TextStyle,
'prototype' | 'Bold' | 'Italics'
>>;
An upside of enum classes is that we can use methods to add behavior to enum values. A downside is that there is no simple way to get an exhaustiveness check: With an as const
enum object whose property values are objects, we can create a discriminated union as an associated type – where we can check exhaustiveness. However, each static property of class TextStyle
simply has the type TextStyle
(the type of the instances of TextStyle
) and that prevents us from creating a discriminated union.
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']
);
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';
What are the pros and cons of this pattern?
Pros:
Cons:
@deprecate
them either.
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 need to do that for string literal unions if, e.g., we want to iterate over their elements (which don’t exist at runtime). Other enum patterns support that out of the box.
We can use a Set to reify a string literal union type:
const activation = new Set([
'Active',
'Inactive',
] as const);
assertType<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>;
type _ = Assert<Equal<
Activation, 'Active' | 'Inactive'
>>;
type SetElementType<S extends Set<any>> =
S extends Set<infer Elem> ? Elem : never;
When it comes to reifying a string literal union, a Set is often the best choice because we can check at runtime if a given string is a member. However, we can also use an Array to do so:
const activation = [
'Active',
'Inactive',
] as const;
type Activation = (typeof activation)[number];
type _ = Assert<Equal<
Activation,
'Active' | 'Inactive'
>>;
In this section, we explore more things we can do with enums. We’ll mostly use enum objects but enums and other enum patterns will also be mentioned occasionally.
Sometimes we want use enum values to look up other values. Let’s explore how that works for the following enum object:
const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');
const Status = {
Pending,
Ongoing,
Finished,
} as const;
type StatusType = ValueOf<typeof Status>;
The utility type ValueOf
was defined previously.
The following Map uses the values of Status
as keys:
const statusPairs = [
[Status.Pending, 'not yet'],
[Status.Ongoing, 'working on it'],
[Status.Finished, 'finished'],
] as const;
type StatusMapKey = (typeof statusPairs)[number][0];
const statusMap = new Map<StatusMapKey, string>(statusPairs);
assertType<
Map<
typeof Pending | typeof Ongoing | typeof Finished,
string
>
>(statusMap);
If you are wondering why we didn’t directly use the value of statusPairs
as the argument of new Map()
and omit the type parameters: TypeScript isn’t 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 statusMap2 = new Map([ // no type parameters!
['Pending', 'not yet'],
['Ongoing', 'working on it'],
['Finished', 'finished'],
] as const);
assertType<
Map<
'Pending' | 'Ongoing' | 'Finished',
'not yet' | 'working on it' | 'finished'
>
>(statusMap2);
As a final step, check manually if we used all enum values:
type _ = Assert<Equal<
MapKey<typeof statusMap>, StatusType // (A)
>>;
type MapKey<M extends Map<any, any>> =
M extends Map<infer K, any> ? K : never;
In line A, we extract the type of the keys from statusMap
and demand that it be equal to StatusType
.
Record
TypeScript can check if a union type is used exhaustively if we use Record
:
Record<UnionType, T>
However, such a union type can only have elements that are subtypes of string
, number
or symbol
. That means that Record
works well for unions of string literal types:
type Status = 'Pending' | 'Ongoing' | 'Finished';
const statusMap = {
'Pending': 'not yet',
'Ongoing': 'working on it',
// @ts-expect-error: Type '{ Pending: string; Ongoing: string; }' does
// not satisfy the expected type 'Record<Status, string>'. Property
// 'Finished' is missing in type '{ Pending: string; Ongoing: string; }'
// but required in type 'Record<Status, string>'.
} satisfies Record<Status, string>;
Sometimes it’s useful to map enum keys to values or vice versa. One important use case for that is deserializing enums of symbols or objects from JSON and serializing them to JSON.
const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');
const Status = {
Pending,
Ongoing,
Finished,
} as const;
One use case for mapping from the key of an enum member to its value, is parsing JSON data:
function parseEnumKey<
E extends Record<string, unknown>
>(enumObject: E, enumKey: string): E[keyof E] {
if (!Object.hasOwn(enumObject, enumKey)) {
throw new TypeError('Unknown key: ' + {}.toString.call(enumKey));
}
return enumObject[enumKey] as any;
}
assert.equal(
parseEnumKey(Status, 'Ongoing'),
Status.Ongoing
);
It is possible to make both the type of enumKey
and the return type more specific (see next subsection) but then we couldn’t parse values of type string
anymore.
One use case for mapping from the value of an enum member to its key, is creating JSON data:
function stringifyEnumValue<
E extends object,
V extends E[keyof E]
>(enumObject: E, enumValue: V): keyof E {
for (const [key, value] of Object.entries(enumObject)) {
if (enumValue === value) {
return key as any;
}
}
throw new TypeError('Unknown value: ' + {}.toString.call(enumValue));
}
assert.equal(
stringifyEnumValue(Status, Status.Ongoing),
'Ongoing'
);
One use case for iterating over enum members is creating a user interface that lists options collected in an enum. Which enum patterns do support iteration?
enumObj
, we can use Object.keys(enumObj)
and Object.values(enumObj)
.
enumClass
, we can use Object.keys(enumClass)
and Object.values(enumClass)
. The own property enumClass.prototype
is not enumerable and therefore ignored by both methods.
One use case for enums is specifying bit vectors (multiple independent bit flags).
The traditional way of specifying bit vectors is via bit masks:
const Permission = {
Read: 1 << 2, // bit 2
Write: 1 << 1, // bit 1
Execute: 1 << 0, // bit 0
}
function setPermission(filePath: string, permission: number): void {
// ···
}
setPermission(
'read-and-write.txt',
Permission.Read | Permission.Write
);
For more information on this kind of bit manipulation, see section “Bitwise operators” in “Exploring JavaScript”.
Another option for specifying bit vectors is via Sets. I often prefer that option.
const Read = Symbol('Read');
const Write = Symbol('Write');
const Execute = Symbol('Execute');
const Permission = {
Read, Write, Execute
} as const;
type PermissionType = (typeof Permission)[keyof typeof Permission];
function setPermission(filePath: string, permission: Set<PermissionType>): void {
// ···
}
setPermission(
'read-and-write.txt',
new Set([Permission.Read, Permission.Write])
);
Zod is a tool for validating parsed data (often JSON) – checking if the runtime data matches an expected type. That increases type safety when working with untyped data.
Zod also supports enums, but the recommendation is to use string literal unions. They have to be defined via Arrays of strings because Zod needs data that it can use at runtime.
import { z } from 'zod';
const ActivationSchema = z.enum(['Active', 'Inactive']);
// Derive a type from the schema
type Activation = z.infer<typeof ActivationSchema>;
type _ = Assert<Equal<
Activation,
'Active' | 'Inactive'
>>;
// Use the schema to “parse” data at runtime
// (check that it has the correct type)
const activation = ActivationSchema.parse('Inactive');
assertType<Activation>(activation);
assert.throws(
() => ActivationSchema.parse('HELLO')
);
How should we choose between enums and various enum patterns?
Let’s go through the use cases:
Various considerations:
As mentioned at the beginning of this chapter, with type stripping and erasableSyntaxOnly
, we can’t use enums. Additionally, the code that enums are transpiled to is a bit difficult to read – especially if an enum has number members.
With string literal unions, there is no way to provide JSDoc comments for the members – which enables deprecation via @deprecated
.
If enum values are strings then comparing can be slower. However, that should only matter if your code does many comparisons.
Enums with strings, symbols and objects are easier to work with than those with numbers – because you see names when you log enum values.