TypeScript is great, but it’s not perfect. Enums, in particular, are a feature you don’t need and should not use.
If you already agree with this, check below for examples of how to avoid enums in your code. If you still need convincing, keep on reading.
Why you shouldn’t use TypeScript Enums
Enums in TypeScript violate the principle of least astonishment, which states:
Every component in a system should behave in a way that most users expect it to behave, and therefore not surprise or astonish them
Enums in TypeScript don’t follow this principle. They behave differently from enums in other programming languages and are inconsistent with TypeScript itself. Let’s see some examples.
enum HttpMethods {
GET,
POST,
}
console.log(HttpMethods.GET);
This code will print 0
. That’s because HttpMethods
is a numeric enum whose members have numeric values starting from zero. In Python, Java, Swift, and other programming languages, a similar code would instead output a special value GET
.
We can fix this by using a string enum instead:
enum HttpMethods {
GET = "get",
POST = "post",
}
console.log(HttpMethods.GET);
This prints "get"
although it’s more verbose. But let’s say we don’t mind the verbosity. There are other issues.
Let’s implement status codes:
enum HttpStatusCodes {
OK = 200,
BAD_REQUEST = 400,
}
console.log(HttpStatusCodes);
/**
* This outputs:
* {
* "200": "OK",
* "400": "BAD_REQUEST",
* "OK": "200",
* "BAD_REQUEST": "400",
* }
*/
HttpStatusCodes
is an object with key/value and value/key pairs. That’s, of course, confusing. You might say that it’s done so that you can access the key from a value:
console.log(`The response is `${HttpStatusCodes[200]}`);
// The response is OK
But then why isn’t the same true for string enums?
console.log(HttpMethods);
/**
* {
* "GET" = "get",
* "POST" = "post",
* }
*/
console.log(HttpMethods["get"]); // undefined
And if you mix numbers and strings, you get this:
enum Mixed {
Foo = 0,
Bar = "bar",
}
console.log(Mixed);
/**
* {
* "0": "Foo",
* "Foo": 0,
* "Bar": "bar"
* }
*/
If this is not confusing enough, let’s focus on the fact that we’re passing an enum to console.log
. This only works because enums, unlike most of the other TypeScript constructs, can be accessed at runtime. Types and interfaces get removed after compilation. Enums are types and objects at the same time.
But there’s more! This code doesn’t pass the type check:
enum HttpMethods {
GET = "GET",
}
enum HttpMethodsCopy {
GET = "GET",
}
const check = HttpMethods.GET === HttpMethodsCopy.GET;
If you come from a language like Java that has nominal typing, this makes total sense to you, but TypeScript—and JavaScript—work with structural typing:
type Method1 = "GET";
type Method2 = "GET";
const method1: Method1 = "GET";
const method2: Method2 = "GET";
const check = method1 === method2; // true
And in some cases enums seem to support structural typing too:
enum HttpMethods {
GET = "GET",
}
enum HttpMethodsCopy {
GET = "GET",
}
const check = HttpMethods.GET === "GET"; // This is OK
Is nominal typing better than structural typing or the other way around? It doesn’t matter! The point is TypeScript always works with structural typing except for enums. Again, this is confusing.
A direct consequence of this behavior is that if you want to use a function from another module that has enums for parameters, you need to import the enum, too:
import fetchData, { HttpMethods } from "./network-module";
fetchData(HttpMethods.GET);
// @ts-expect-error
fetchData("GET");
Ok, but what about const enums and ambient enums? They are a bit better and fix some of the quirks, but they have their own set of issues. You can read more about them in the official TypeScript documentation.
That’s the gist of it. Enums are not bad or harmful, but they behave in ways that are not always obvious, and they don’t follow TypeScript’s conventions.
Why you don’t need enums
We agree that enums shouldn’t be used. The good news is that you don’t need them. Let’s see what you can use instead.
Define a list of possible values
This is the main reason people reach for enums: to define a list of possible values. Enums can define directions, months, states in a process, or anything that makes sense to group together.
Take this code:
enum Direction {
Up,
Down,
Left,
Right,
}
function goToDirection(direction: Direction) {
switch (direction) {
case Direction.Up:
console.log("Going up");
break;
case Direction.Down:
console.log("Going down");
break;
case Direction.Left:
console.log("Going left");
break;
case Direction.Right:
console.log("Going right");
break;
}
}
goToDirection(Direction.Up);
Instead, you can use a union type:
type Direction = "Up" | "Down" | "Left" | "Right";
function goToDirection(direction: Direction) {
switch (direction) {
case "Up":
console.log("Going up");
break;
case "Down":
console.log("Going down");
break;
case "Left":
console.log("Going left");
break;
case "Right":
console.log("Going right");
break;
}
}
goToDirection("Up");
Or even simpler, without the switch
:
type Direction = "Up" | "Down" | "Left" | "Right";
const DIRECTION_TO_MESSAGE: Record<Direction, string> = {
Up: "Going up",
Down: "Going down",
Left: "Going left",
Right: "Going right",
};
function goToDirection(direction: Direction) {
console.log(DIRECTION_TO_MESSAGE[direction]);
}
goToDirection("Up");
Have a readable value or avoid magic values
Sometimes, you need to use a value that is either hard to read or is not clear. In these cases, you might be tempted to use an enum to hide the value behind a more readable label.
In this example, we are implementing feature flags, but their values are not meant to be read by a human:
enum FeatureFlags {
EnableSuperUser = "feature_flag_enable_super_user_123abc",
BetaPayment = "ff-cod-abef8f76aa",
}
type FeatureFlagKeys = keyof typeof FeatureFlags;
type FeatureFlagValues = (typeof FeatureFlags)[FeatureFlagKeys];
declare const featureFlags: Map<FeatureFlagValues, boolean>;
function isFlagActive(featureFlag: FeatureFlags) {
return featureFlags.has(featureFlag);
}
isFlagActive(FeatureFlags.BetaPayment);
We can achieve the same result without enums by using a simple object. Since we know the mapping won’t change at runtime, we’re going to add as const
to get better typing:
const featureFlagsMappings = {
EnableSuperUser: "feature_flag_enable_super_user_123abc",
BetaPayment: "ff-cod-abef8f76aa",
} as const;
type FeatureFlagKeys = keyof typeof featureFlagsMappings;
type FeatureFlagValues = (typeof featureFlagsMappings)[FeatureFlagKeys];
declare const featureFlags: Map<FeatureFlagValues, boolean>;
function isFlagActive(featureFlag: FeatureFlagKeys) {
return featureFlags.has(featureFlagsMappings[featureFlag]);
}
isFlagActive("EnableSuperUser");
Alternatively, you can do the inverse and use the mapping object to call the method—although in this way you have to make the object available to the caller:
const featureFlagsMappings = {
EnableSuperUser: "feature_flag_enable_super_user_123abc",
BetaPayment: "ff-cod-abef8f76aa",
} as const;
type FeatureFlagKeys = keyof typeof featureFlagsMappings;
type FeatureFlagValues = (typeof featureFlagsMappings)[FeatureFlagKeys];
declare const featureFlags: Map<FeatureFlagValues, boolean>;
function isFlagActive(featureFlag: FeatureFlagValues) {
return featureFlags.has(featureFlag);
}
isFlagActive(featureFlagsMappings.EnableSuperUser);
Do you know of any use case not covered here for which enums are better or have no alternative? Please let me know! You can reach out to me on Twitter/X or write to me.