Typescript enums: Why you should use string values
Enumerations are found in almost every programming language there is. They're a language facility which allow developers to group together a set of named identifiers which act as constant values. You can use them almost anywhere you could use a union, but while your programming language of choice might not have support for union types it will almost certainly support enumerations.
Typescript has both enumerations and unions, and they each offer different tradeoffs in terms of developer productivity, type safety, and runtime performance. In this article I'll be focusing on enumerations
The Typescript enumeration
Enumerations are defined simply in Typescript:
enum Color { Red, Green, Blue,}
The above code defines an enumeration Color
, with identifiers Red
, Green
, and Blue
. By default, the values of those identifiers will start at 0
and increment by one for each identifier. Concretely, this means that Color.Red === 0
, Color.Green === 1
, and Color.Blue === 2
.
The enumeration can be used like any other type, as in this getColorName
function:
function getColorName(color: Color) { switch (color) { case Color.Red: return 'red'; case Color.Green: return 'green'; case Color.Blue: return 'blue'; default: throw new Error(`Unknown color: ${color}`) }} expect(getColorName(Color.Red)).toBe('red');
Enumerations are great because you get nice autocomplete (just type Color.
in your editor of choice and pick the correct option) and they feel much nicer to use than constants. In situations where you're working with legacy code and need to customize the value of each enumeration member, you can do that like so:
// Static valuesenum Color { Red = 10, Green = 50, Blue = 1_000,} // Dynamic valuesenum Color { Red = 1 << 0, Green = 1 << 1, Blue = 1 << 2,} // String valuesenum Color { Red = 'red', Green = 'green', Blue = 'blue',}
In general, you should avoid using enumerations without string values. Numeric enumerations suffer from a number of unfortunate limitations which–among other things–greatly weaken their type safety guarantees.
The downsides of numeric enumerations
Weak type safety
Believe it or not, the following code compiles:
enum Color { Red, Green, Blue,} function getColorName(color: Color) { // ...} getColorName(5);
Any number at all can be implicitly cast to a numeric enumeration even when that particular number is not a valid value for the enumeration which compromises the type safety of numeric enumerations. String enumerations, on the other hand, are far more strict:
enum Color { Red = 'red', Green = 'green', Blue = 'blue',} function getColorName(color: Color) { // ...} getColorName('foo'); // errorgetColorName('red'); // error
String enums prevent you from passing arbitrary strings which are outside the set of allowable values (such as 'foo'
in the previous example), and they also prevent you from passing in valid values in the form of a string value. There's a massive win to type safety here, because you are forced to use values from the enum (e.g. getColorName(Color.Red)
) instead of being allowed to pass in any number or any string that you want to.
Reverse mapping
The transpiled Javascript for the first enumeration I showed in this article looks something like the following:
"use strict";var Color;(function (Color) { Color[Color["Red"] = 0] = "Red"; Color[Color["Green"] = 1] = "Green"; Color[Color["Blue"] = 2] = "Blue";})(Color || (Color = {}));
See how each line has two equal symbols? When you create a numeric enumeration, Typescript adds in a reverse mapping. This is useful in some situations where you want to determine whether a key and/or value is valid for a given enumeration:
'Red' in Color; // -> true0 in Color; // -> true'foo' in Color; // -> false100 in Color; // -> false
But it means that programmatically grabbing all of your enumeration's values via something like Object.values
will also give you all of the enumeration's identifiers. String based enumerations, on the other hand, do not have reverse mappings which makes it much easier to programmatically grab its keys and values programmatically.
While reverse mappings can be useful in certain niche circumstances, in general I find it much better to have a clean separation between the identifiers of the enumeration and its values.
Poor debugging experience
Consider the following Typescript code–what do you expect will be logged to the console?
enum Shape { Square, Circle, Triangle,} enum Color { Red, Green, Blue,} interface Entity { color: Color; shape: Shape;} const myEntity: Entity = { color: Color.Red, shape: Shape.Circle,};console.log(myEntity);
What does myEntity
look like once it's been logged? Because our enumeration values are just numbers, we get those numbers come through when we actually run our code–which doesn't have great glance value:
In the given example it's at least somewhat obvious that the number 0 refers to a member of the Color
enumeration and that the number 1 refers to a member of the Shape
enumeration, but if you were to see these values logged in the wild in a real system with hundreds of different enumerations it would be very difficult to immediately recognize the meaning of these values.
String-based enumerations, on the other hand, log much nicer: you get to see the strings that you defined inside the enumeration's declaration in your logging output, which is far nicer–the difference between seeing "Circle"
in your logs instead of the number 1 cannot be overstated, and you'll wind up saving time whenever you find yourself needing to debug some misbehaving bit of code.
The other option: union types
The point of this article is to convince you to use string enumerations instead of numeric enumerations, but it is worth touching on some of the obvious advantages to using union types. Enumerations are one of the few Typescript concepts which continue to exist after you run tsc
as they get compiled down to a real object. The consequence of this is that each enumeration you add to your project will slightly increase your bundle size.
How much? Well, the Color
enumeration we've been looking at with 3 different possible values adds about 90 bytes after transpilation and minification. A lot of that is fixed overhead–57 bytes of that minified code is dedicated to just setting up the object which stores the enumeration values, as the emitted code needs to support declaration merging.
On the other hand, union types declarations like type Color = 'red' | 'green' | 'blue';
are completely stripped out by the Typescript compiler and therefore have zero impact on bundle size. The tradeoffs you make have to do with developer experience: while the Typescript language server supports automatic refactoring of both enums and union types equally well, and you still get nice autocompletion you lose out on two things:
- You can't grep for uses of a union type as easily as you can an enumeration. Because the enumeration has an identifier, it's easy to search for all uses of that enumeration in your codebase.
- You cannot navigate to the definition of your union type from a callsite which uses the union. When you see a function call like
getColorName(Color.Red)
in VSCode you can ⌘+Click on theColor
symbol and get taken straight to the enumeration–but you can't do that with code likegetColorName('red')
. Your best option in that case is to navigate to the function declaration where you can then use the parameter's type annotation, but it adds one extra step compared to code using enumerations.
The bundle size consideration is important. While 90 bytes doesn't sound like a lot, a hundred enumerations means you're spending 9 kB of your 170 kB budget on just types.
But if you are using enumerations in your Typescript code, you should be using string values. Numeric enums simply make too many tradeoffs.