https://2ality.com/2025/01/typescript-enum-patterns.html 2ality - JavaScript and more Home|About|Follow|Archive|Search #|Books # TypeScript enums: use cases and alternatives [2025-01-19] dev, typescript (Ad, please don't block) In this blog post, we take a closer look at TypeScript enums: * How do they work? * What are their use cases? * What are the alternatives if we don't want to use them? The blog post concludes with recommendations for what to use when. * Notation * The basics of TypeScript enums + An enum defines an object + An enum defines a type + We can check exhaustiveness for enums + Enumerating members + Enums without explicitly specified values * Use cases for enums * Use case: namespace for constants with primitive values + Enum as namespace for constants with primitive values + Alternative to enum: object literal * Use case: custom type with unique values + Enum as custom type with unique values + Alternative to enum: object literal + Alternative to enum: union of string literal types * Use case: namespace for constants with object values + Object literal whose property values are objects + Enum class * Mapping to and from an enum * Recommendations Notation # For showing inferred types in the source code, I use the npm package ts-expect - e.g.: // Types of values expectType('abc'); expectType(123); // Equality of types type Pair = [T, T]; expectType, [string,string]>>(true); The basics of TypeScript enums # There are many different kinds of enums in various programming languages. In TypeScript, an enum defines two things: * an object that maps member keys to member values * a type that only contains the member values Note that we are ignoring const enums in this blog post. Next, we'll look at various aspects of enums in more detail. An enum defines an object # 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. An enum defines a type # 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; We can check exhaustiveness for enums # 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); // (A) if (color === Color.Red) { return 'red'; } expectType(color); // (B) if (color === Color.Green) { return 'green'; } expectType(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. Enumerating members # 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'] ); Enums without explicitly specified values # 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); Use cases for enums # Making sense of enums and enum-related patterns can quickly get confusing because what an enum is varies widely between programming languages - e.g.: * Java's enums are classes with a fixed set of instances. * Rust's enums are more like algebraic datatypes in functional programming languages. They are loosely related to discriminated unions in TypeScript. Therefore, we benefit from a narrow definition of the term enum: * A fixed set of values. * That can be accessed via the keys of an object. In the following sections, we'll go through the following use cases for this kind of enum: 1. Namespace for constants with primitive values 2. Custom type with unique values 3. Namespace for constants with object values 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: * Can exhaustiveness checks be performed? * Can members be enumerated? Use case: namespace for constants with primitive values # 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; Enum as namespace for constants with primitive values # Which enum features are relevant for this use case? * One major limitation of enums is that values can only be number or strings. * The enum as a type doesn't matter: The type of parameter 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 the same reason, exhaustiveness checks are not relevant in this case. * Enumerating members isn't desirable either. And it's not something that we can do with number-valued enums anyway. Alternative to enum: object literal # 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: * The main benefit is that we can use the in operator to check if constants has a given key without worrying about properties inherited from Object.prototype such as .toString. * However, 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": * "The pitfalls of using an object as a dictionary" * "Tips for working with prototypes" * "Object.prototype.__proto__ (accessor)" Use case: custom type with unique values # 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 =: * We get more type safety and can't accidentally provide numbers where an Activation is expected. * We can enumerate the keys and values of Activation. Enum as custom type with unique values # Which enum features are relevant for this use case? * We will use the type defined by Activation. * Exhaustiveness checks are possible and useful. * We also may want to enumerate keys or values. Alternative to enum: object literal # 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; expectType< TypeEqual >(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[Exclude]; * The type Obj[K] contains the values of all properties whose keys are in K. * We exclude the key '__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(Activation.Active); expectType(Activation.Inactive); type ActivationType = PropertyValues; expectType< TypeEqual >(true); Exhaustiveness checks # 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); } } Enumerating members # 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 Using symbols as property values # 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(Activation.Active); type ActivationType = PropertyValues; 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(Activation.Active); type ActivationType = PropertyValues; expectType< TypeEqual >(true); Alternative to enum: union of string literal types # 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: * It's a quick and simple solution. * It supports exhaustiveness checks. * Renaming members works reasonably well in Visual Studio Code. Cons: * The type members are not unique. We could change that by using symbols but then we'd lose some of the convenience of string literal union types - e.g., we'd have to import the values. * We can't enumerate the members. The next section explains how to change that. Reifying string literal unions via Sets # 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>(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; expectType< TypeEqual >(true); type SetElementType> = S extends Set ? Elem : never; Use case: namespace for constants with object values # 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. Object literal whose property values are objects # 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; type TextStyleType = PropertyValues; type PropertyValues = Obj[Exclude]; Exhaustiveness check # 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); } } Enum class # 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; expectType< TypeEqual >(true); type EnumKeys = Exclude; 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'] ); Mapping to and from an enum # 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; type PropertyValues = Obj[Exclude]; 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(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, TaskStatusType> >(true); type MapKey> = M extends Map ? K : never; Recommendations # 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: * The transpiled code looks a bit strange - especially if some enum members are numbers. * If a tool doesn't transpile TypeScript but only strips types then it won't support enums. That's not (yet?) that common but one prominent example is Node's current built-in support for TypeScript. 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: 1. Namespace for constants with primitive values: + If the primitive values are numbers or strings, we can use a TypeScript enum. o Alas, number values aren't great because each member produces two properties: a mapping from key to value and a reverse mapping. + Otherwise (or if we don't want to use an enum) we can use an object literal. 2. Custom type with unique values: + If we use an enum, it should have string values because that gives us more type safety and lets us iterate over keys and values. + A union of string literal types is a lightweight, quick solution. Its downsides are: less type safety and no namespace object for easy lookup. o If we want to access the string literal values at runtime, we can use a Set to reify them. + If we want a solid, slightly verbose solution, we can use an object literal with symbol property values. 3. Namespace for constants with object values: + We can't use enums for this use case. + We can use an object literal whose property values are objects. The upside of this solution is that we can check exhaustiveness. + If we want enum values to have methods, we can use an enum class. However that means that we can't check exhaustiveness. Please enable JavaScript to view the comments powered by Disqus. Headshot of Dr. Axel Rauschmayer Dr. Axel Rauschmayer Homepage | Mastodon Cover of the book "JavaScript for impatient programmers (ES2022 edition)" by Axel Rauschmayer. It shows a rhinoceros. JavaScript for impatient programmers Book (free online), exercises, quizzes Cover of the book "Deep JavaScript" by Axel Rauschmayer. It shows a freediver who swims under water and follows a rope down into the deep. Deep JavaScript Book (free online) Cover of the book "Tackling TypeScript" by Axel Rauschmayer. It shows a a pair of boxing gloves. Tackling TypeScript Book (free online) Cover of the book "Shell scripting with Node.js" by Dr. Axel Rauschmayer. It shows a field of light blue hexagonal bars, with three yellow ones in the middle. Node.js shell scripting Book (free online) Logo with text "ES News" ECMAScript News Newsletter (free)