How I feel about TypeScript enums
A couple of years back, a colleague sent me an article detailing all the horrible things wrong with TypeScript enums. I read it, then I Googled "Are enums bad in TypeScript?" and read as much as I could find. OK, I was convinced, union types are much simpler than enums. They don't have any weird behaviour, you don't have to worry about type vs value confusion. Union types are types - they get stripped out at compile time. String literals are plain old JavaScript, they stay in at compile time.
This came after a couple of years of using enums in TypeScript and actually rather enjoying it. I created a chess board application which had enums littered throughout and I really thought I was a pro TypeScript developer with that!
Alas, my illusions of grandeur came tumbling down around me as I realised that I had been blundering badly all along. Enums are bad, man!
Imagine my suprise when I spotted a (present day) colleague introducing an enum into our React+TypeScript codebase. Oh, the horror!
I relished the opportunity to explain in my PR comment about why enums were bad - they're obfuscated, they produce weird run-time code, they have to be imported and used as literal values everywhere that expects that type.
I stand by those criticisms - enums can be pretty ugly and I still find them confusing. The blurred line between type definition and literal values is not an easy concept for me to understand. After abondoning enums in favour of union types around two years ago, I hadn't really looked back, but had I been missing something
Let's take a look into what the pros and cons of enums actually are, and when to consider using each one.
What are enums and when I use them
TL;DR: Application code can use enums, library code should not
The long and short of it is that defining and reusing enums throughout an application seems to be more or less ok, under certain conditions, but using them in libraries is far from ideal.
Why? Simply because you need to import the enum itself from the library whenever using a function/component that relies on an enum input type.
Let's consider a simple yet probably quite widespread example. We have a button that can have different variants. Our theme allows for primary or second buttons, with perhaps more to come in the future.
enum ButtonVariant {
primary = "primary",
secondary = "secondary",
}
const Button = ({
text,
variant,
}: {
text: string;
variant: ButtonVariant;
}) => {
const className = `button ${
variant === ButtonVariant.primary ? "button--primary" : "button--secondary"
}`;
return <button className={className}>{text}</button>;
};
Alright, looks pretty simple, right? Our button can be primary or secondary, nice. We'll have some styles (somewhere) that determine how that looks. So, what's the issue?
Well, if this is inside a single codebase, nothing. But if this is in library code - either from an external source or within the same company's projects - this is going to be a pain.
For one thing, we need to export the enum out of the library, exposing additional values from our library. Not so reusable, right? And our consuming application will have to import it and refer to the variant in the same way. Like so:
const MyComponent = () => {
return (
<>
...some other code
<Button text={"Save"} variant={ButtonVariant.primary} />
</>
);
};
Of course, we're getting type safey here, but we would also get that from just having a union type. This works much better:
type ButtonVariant = "primary" | "secondary";
It means we can pass string literals into the <Button>
component when using it anywhere else, and we don't need to worry about how that type is defined. Much better.
Scenarios in which I would use enums
OK, having ruled out library as one place to use enums, let's take a look at an example of where we might want to call on our trusty enums to help us out - application code.
Now we're staying inside a single codebase and we can more comfortably pass around our enum without too much concern. Let's say we have some user roles in our application. A single user can only have one role at a time and this determines what actions they are allowed to perform.
We can define an enum for this like so:
enum UserRole {
Admin,
Editor,
Reader,
}
This definition is perfectly allowed, but it can lead to some confusing behaviour with the Object that is returned when the resulting JavaScript code is inspected.
If we check the compiled output, we see:
var UserRole;
(function (UserRole) {
UserRole[(UserRole["Admin"] = 0)] = "Admin";
UserRole[(UserRole["Editor"] = 1)] = "Editor";
UserRole[(UserRole["Reader"] = 2)] = "Reader";
})(UserRole || (UserRole = {}));
What in the name of holy f*ck is that all about? It's confusing for one thing, and when we break it down into what that would actually look like, it's basically a very confused JavaScript object.
{
0: 'Admin',
1: 'Editor',
2: 'Reader',
'Admin': 0,
'Editor': 1,
'Reader': 2,
}
Well, that's not very helpful. If we wanted to iterate through that object, it would probably not give us what we're expecting and would likely cause some strange bugs in our code.
Let's start by fixing the enum defintion:
enum UserRole {
Admin = "Admin",
Editor = "Editor",
Reader = "Reader",
}
This compiles down, much more predictably, to:
"use strict";
var UserRole;
(function (UserRole) {
UserRole["Admin"] = "Admin";
UserRole["Editor"] = "Editor";
UserRole["Reader"] = "Reader";
})(UserRole || (UserRole = {}));
which means out object looks much better:
{
'Admin': 'Admin',
'Editor': 'Editor',
'Reader': 'Reader',
}
OK, at least we know where we stand with this object now, and it would be much better working with that than the previous example.
So how can we actually use it?
We can use it as a component prop in a React component, of course.
const ROLE_CLASS_MAP: Record<UserRole, string> = {
[UserRole.Admin]: "component--red",
[UserRole.Editor]: "component--yellow",
[UserRole.Reader]: "component--green",
} as const;
const MyComponent = ({ userRole }: { userRole: UserRole }) => {
const className = `component ${ROLE_CLASS_MAP[userRole]}`;
return <div className={className}>...some component content</div>;
};
This is quite a clear usage, and once you get your mind around the dynamic object literal keys, it becomes quite easy to read and reason about.
"Wait", I hear you interjecting, "how is this any better or even different from a union type? The enum wouldn't get passed around like cold at a kid's birthday party and we get the same behaviour, right?"
Well, yes. Except not quite.
The way it is right now, we're good. TypeScript would be happy in either case and our enum isn't doing much more than a union type would.
However, once we start tampering with the enum, in case we added an additional role, let's say, TypeScript would give us a heads up that something is missing from out role class map.
That's because having the enum as the key type in our object literal means that it has to be exhaustively defined.
enum UserRole {
Admin = "Admin",
Editor = "Editor",
Contributor = "Contributor",
Reader = "Reader",
}
If we added this Contributor
role to our enum, then TypeScript would error at compile time because our role class map is missing an entry - there's no Contributor
value defined!
Pretty neat!
With a union type we wouldn't get this level of safety, making this a definite step up, especially in this case where we're indexing into an Object literal with the enum type.
One slight drawback of using enums is getting a fixed type of the values. What do I mean by that?
Let's say we have the following enum:
enum Foo {
A = "a",
B = "b",
}
Obviously, this is meaningless, but let's say we want a type of the values only. In this case, the union type (which is what we want) would be simple
type FooValues = "a" | "b";
but I want to map this type from my enum rather than hardcoding it. How I would typically do this would be to define a Plain Old JavaScript Object as const
and then I can define a type of the keys/values at will, like so:
const Foo = {
A: "a",
B: "b",
} as const;
type FooValues = (typeof Foo)[keyof typeof Foo]; // 'a' | 'b'
This can look a little strange at first sight, but it gives us the union type 'a' | b'
, allowing us to scatter this type where we need it. Tip: we need as const
here to tell TypeScript that this is immutable data and that it can infer the exact strings as the value types. Without this, it would simply fall back to string
.
For enums, however, it looks like this:
enum Foo {
A = "a",
B = "b",
}
type FooValues = `${Foo}`;
While this is shorter and more concise, I find it nowhere near as readable as the as const
version above. Perhaps it's a matter of familiarity. After all, the first example was also bizarre to me the first time I saw it, and it still takes me a minute to remember exactly what 'typeof' I need to do, but I can at least reason about it.
One other benefit we can get from enums, however, is the powerful refactoring possibilities they offer. Due to the limited way we can refer to an enum value, like UserRole.Admin
, our code becomes very structured in how the values are used. If a value inside of our enum needs to change, for example, it's trivially simple to find and replace all occurrences of UserRole.Admin
with the new key.
Likewise, renaming the enum is trivilly simple providing the same name has not been used for two different enums in different parts of the application - a very bad code smell, just in case you're not sure.
Which one should I use?
The discussion around enums is not quite as over as I thought, but I'm definitely more open to putting them into use now than I was previously.
In my work projects, we have now defined rough guidelines about how and where to use enums in our TypeScript code, and this more or less helps us to know whether to rely on the good, old union types or stretch into the world of enums.
In my personal projects, I think I will probably stick with union types for the time being, unless I stumble upon a very enum-oriented case. My knowledge of union types now is far better than enums, and like I mentioned at the start, I still get a little confused around when it refers to its type definition and when it refers to its literal value defintion. I'm not a big fan of this ambiguity, and there's a lot to be said for sticking with what you know.