Should we use TypeScript enums?

Should we use TypeScript enums? Looking to replace your TypeScript enums? Consider union types or POJOs with as const annotations.
January 12, 2023TypeScript
9 min read

This blog post contains my learnings from watching the video Enums considered harmful by Matt Pocock.

TypeScript enums can be a controversial topic. Should we use them? Some TypeScript developers love them, while others hate them. Let's take a look at the pros and cons of TypeScript enums to see if they are right for your next use case.

Intro to the TypeScript enum

The TypeScript enum is not native to JavaScript. There is an ECMAScript proposal to bring enums to JavaScript, but as of today, enums are not in JavaScript.

Enums were introduced early on in TypeScript to give it an object-oriented feeling, inspired by C#. Anders Hejlsberg, lead architect of C# at Microsoft, said that if TypeScript was created today, they probably wouldn't put enums in the language.

Using an enum

Below is a standard TypeScript enum. By default, each enum key will get a numeric value, giving DEBUG a value of 0, WARNING a value of 1, and so forth.

enum LogLevel {
	DEBUG,
	WARNING,
	ERROR
}

We have the option to explicitly state the numeric values for enum keys.

enum LogLevel {
	DEBUG = 0,
	WARNING = 1,
	ERROR = 2
}

We can also give string values to enum keys.

enum LogLevel {
	DEBUG = 'DEBUG',
	WARNING = 'WARNING',
	ERROR = 'ERROR',
}

Enums are unpredictable

Enums behave unpredictably at runtime. Let's revisit the enum we defined earlier.

enum LogLevel {
	DEBUG,
	WARNING,
	ERROR
}

We saw that, by default, the value for DEBUG will be 0, and so forth. So, we would think that at runtime, our enum will end up looking something like this.

enum LogLevel {
	DEBUG = 0,
	WARNING = 1,
	ERROR = 2
}

However, this is actually not the case. If we look at the transpiled JavaScript, LogLevel ends up looking like a totally different object.

"use strict";
var LogLevel;
(function (LogLevel) {
	LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG";
	LogLevel[LogLevel["WARNING"] = 1] = "WARNING";
	LogLevel[LogLevel["ERROR"] = 2] = "ERROR";
})(LogLevel || (LogLevel = {}));

First, LogLevel["DEBUG"] is assigned to 0. Then, the result of that, which is 0, is used as an index for LogLevel, resulting in LogLevel[0] = "DEBUG". This is done for each key.

The LogLevel enum ends up producing an object that looks like the following.

const LOG_LEVEL = {
	DEBUG: 0,
	0: 'DEBUG',
	WARNING: 1,
	1: 'WARNING',
	ERROR: 2,
	2: 'Error',
}

If we try getting all the values of an enum and output them, we will get an unexpected result.

enum LogLevel {
	DEBUG,
	WARNING,
	ERROR
}

console.log(Object.values(LogLevel));
// ["DEBUG", "WARNING", "ERROR", 0, 1, 2] 

We would have expected to get just the values [0, 1, 2] but instead we got the enum's keys as strings too. Enums can be annoying because they don't behave as we might expect them to.

String enum values are not implicit

String enum values are more predictable than numeric enum values that we looked at previously.

For example, consider the following enum with string values.

enum LogLevel {
	DEBUG = 'DEBUG',
	WARNING = 'WARNING',
	ERROR = 'ERROR',
}

At runtime, this results in the creation of the following object.

"use strict";
var LogLevel;
(function (LogLevel) {
	LogLevel["DEBUG"] = "DEBUG";
	LogLevel["WARNING"] = "WARNING";
	LogLevel["ERROR"] = "ERROR";
})(LogLevel || (LogLevel = {}));

The downside to string value enums is that the string values do not get automatically assigned. We must explicitly state the values by defining DEBUG = 'DEBUG' within the enum.

Passing an enum value

function log(message: string, level: LogLevel) {
	console.log(`${LOG_LEVEL[level]}: ${message}`);
}

log('test', 'DEBUG'); // error

The log function should not care if we pass 'DEBUG' as a string or LogLevel.DEBUG from the LogLevel enum, because both values are the same.

TypeScript does not usually care about names, only the runtime values. Therefore, it should not care if we pass 'DEBUG' as a string or LogLevel.DEBUG from the LogLevel enum, because both values are the same.

However, enums break a rule in TypeScript. Enums cause TypeScript to care about the name of things. For enums, TypeScript becomes a
type system that now cares about names.

Furthermore, TypeScript is picky about whether a LogLevel enum is used or a LogLevel2 enum is used.

enum LogLevel2 {
	DEBUG = 'DEBUG',
	WARNING = 'WARNING',
	ERROR = 'ERROR',
}

function log(message: string, level: LogLevel) {
	console.log(`${LOG_LEVEL[level]}: ${message}`);
}

log('test', LogLevel2.DEBUG); // error

In the above example, we can't assign LogLevel2 to the second parameter in the log function call. A value of type LogLevel is expected. Even if LogLevel and LogLevel2 have the same values, they have a different name and this does not make them interchangeable.

Const enums

Are const enums better? Let's find out by taking a look at a similar example that uses LogLevel, but as a const enum this time.

const enum LogLevel {
	DEBUG = 'DEBUG',
	WARNING = 'WARNING',
	ERROR = 'ERROR',
}

function log(message: string, level: LogLevel) {
	console.log(`${LOG_LEVEL[level]}: ${message}`);
}

log('test', LogLevel.Debug); // "DEBUG: test"

At runtime, the result is the following.

"use strict";

function log(message, level) {
	// ...
}

log('a', "DEBUG" /* LogLevel.Debug */);

The const enum itself disappears at runtime. It gets stripped out and is only added as an inline comment wherever it is used. It only exists in TypeScript at the type level.

It almost seems like a good compromise because it gives us type safety with enums and it won't confuse us, since the runtime structure is inaccessible.

However, the TypeScript documentation mentions that there are const enum pitfalls. This could make using const enums problematic. To avoid these potential issues, it's better to use a standard enum or go with a simpler solution, such as union types or POJOs with an as const annotation.

Union types

Union types are an alternative to enums. Union types are basically just a union of strings. They do not require creating an object representation. Also, the code for union types disappears at runtime.

type LogLevel = 'DEBUG' | 'WARNING' | 'ERROR';

function log(message: string, level: LogLevel) {
	console.log(`${level}: ${message}`);
}

log('test', 'DEBUG'); // "DEBUG: test"

At runtime, this results in the following.

"use strict";
function log(message, level) {
  console.log(`${level}: ${message}`);
}
log('test', 'DEBUG');

As we can see, the code for the LogLevel union type that we defined has disappeared at runtime.

The biggest issue with using union types is that it will result in plenty of duplicated string values in the codebase. If we use the 'DEBUG' string in several files, but then decide to rename it one day, renaming all occurrences can be a tedious process.

The POJO approach

An alternative to enums is a POJO with an as const annotation. POJO stands for a Plain Old JavaScript Object. The as const annotation means that the object cannot be manipulated or changed.

Let's take a look at what a POJO looks like for the log level.

const LOG_LEVEL = {
  DEBUG: 'DEBUG',
  WARNING: 'WARNING',
  ERROR: 'ERROR',
} as const;

The LOG_LEVEL POJO is a mapping of log levels to a human readable version of each log level.

Let's see how we can now use this POJO as a parameter to the log function.

type ObjectValues<T> = T[keyof T];
type LogLevel = ObjectValues<typeof LOG_LEVEL>;

function log(message: string, level: LogLevel) {
	console.log(`${LOG_LEVEL[level]}: ${message}`)
}

log('test', 'DEBUG'); // "DEBUG: test"
log('test', LOG_LEVEL.DEBUG); // also works

Let's break down this code. First off, in order to use the POJO as a TypeScript type, we had to apply some type magic to it.

type ObjectValues<T> = T[keyof T];
type LogLevel = ObjectValues<typeof LOG_LEVEL>;

These two lines are where the type magic happens. The type magic first extracts the type out so that we can use it. The type magic then extracts the POJO's object values into LogLevel so that it ends up as either the string 'DEBUG', 'WARNING', or 'ERROR'.

The only downside to this approach is the type magic that is needed.

The advantage of the POJO approach are the following:

  • It does not require importing a log level entity every time it needs to be used in a file. We can just pass along the string value that we need, as we did with the function call to log('test', 'DEBUG').

  • This approach is also flexible enough to allow us to use LOG_LEVEL.DEBUG in case we prefer using that instead of directly referencing the 'DEBUG' string.

  • No unexpected results at runtime. It works at the runtime level just as you would expect it to work at the type level.

The 'as const' flexibility

The great thing about the as const annotation is that it allows us to be flexible with the object we create our keys in.

We can create our POJO as a mapping between log levels and human readable level names.

const LOG_LEVEL = {
  DEBUG: 'Debug',
  WARNING: 'Warning',
  ERROR: 'Error',
} as const;

type LogLevel = keyof typeof LOG_LEVEL;

function log(message: string, level: LogLevel) {
	console.log(`${LOG_LEVEL[level]}: ${message}`)
}

log('test', 'DEBUG'); // "Debug: test"

Instead of the log level being extracted from the values (as in the previous POJO example), it's being extracted from the keys in this example. This is why we can pass 'DEBUG' into LOG_LEVEL['DEBUG'] and get 'Debug' in human readable form.

Replicating the POJO with an enum

It's possible to replicate the POJO that we saw above using enums. To do so, we'll need to complete the following steps.

  1. Declare the enum.
  2. Create an enum mapping to human readable names.
  3. Use the enum in the log function.

Let's take a look at the code for doing this.

enum LogLevel {
	DEBUG,
	WARNING,
	ERROR
}

const titlesMap = {
  [LogLevel.DEBUG]: 'Debug',
  [LogLevel.WARNING]: 'Warning',
  [LogLevel.ERROR]: 'Error',
}

function log(message: string, level: LogLevel) {
	console.log(`${titlesMap[level]}: ${message}`)
}

log('test', LogLevel.DEBUG); // "Debug: test"

As we can see, using an enum instead of the POJO approach also works well. It just involves writing slightly more code.

Conclusion

If and when enums make it into the JavaScript language, we will be able to use them more predictably in TypeScript. For now, enums are only in TypeScript, which can make them unpredictable in runtime JavaScript code.

If you're looking to move away from enums, consider using union types or POJOs with an as const annotation

Despite their shortcomings, enums are not all that bad. Enums make refactoring easy. It's easy to rename enums, rename enum values, and to move them around. Also, enums work well with Visual Studio Code autocomplete.

New
Be React Ready

Learn modern React with TypeScript.

Learn modern React development with TypeScript from my books React Ready and React Router Ready.