TypeScript Enums - is there a more

by Simeon Petrov

Thumbnail for TypeScript Enums - is there a more
  • TYPESCRIPT

In my opinion TypeScript enums aren’t really THAT bad, but they’re a little bit … clumsy. And when I say clumsy I mean that often than not, you need to go out of your way to make seemingly logical stuff to work. As a lazy developer, that’s not really my favorite thing - I want TS to help me, not to fight with it over types, strings and juggling the inner devil to use as any and call it a day.

Let’s talk about all of this.

TS Enums have … number values ?!

If this is the first time when you’re defining an enum, you’d do something like this:

enum Status {
    NEW,
    PENDING,
    DONE
}

// 👇 You can set the enum as a type - that's really nice
const myStatus: Status = Status.NEW;
// 👇 But this console.log is showing 0 instead of NEW
console.log(myStatus) // -> 0

By “default” (or when you don’t set anything to your key as a value) the TS enums are Numeric enums . While in the docs they state that these “are probably more familiar if you’re coming from other languages”, for me personally that’s not really the case. I have a “NEW” status, I want a “NEW” status… right?

Even if I’m in the wrong here (as per the usual situation), this is really the way I’m used to handing Enums in my line of work. So let’s see what we can do to adjust that.

TS String enums

In the docs is said that “In a string enum, each member has to be constant-initialized with a string literal, or with another string enum member.” This means we need to adjust a little bit our example from above, like this

enum Status {
  NEW = "NEW",
  PENDING = "PENDING",
  DONE = "DONE",
}

// 👇 Completely the same
const myStatus: Status = Status.NEW;
// 👇 Now we finally see the value "NEW"
console.log(myStatus); // -> "NEW"

This now makes a little bit more sense for my brain. But the “issue” here is that you need to duplicate every value… a little bit tedious, but we can live with this.

Also when we have string enums like these we can be a little bit more flexible with these. Let’s say you have some kind of select component, which returns you your Status string value and you need to save it:

enum Status {
  NEW = "NEW",
  PENDING = "PENDING",
  DONE = "DONE",
}
// 👇 We assign our "selected" string to our variable, but...
// 🛑 Type '"NEW"' is not assignable to type 'Status'.ts(2322)
const selectedStatus: Status = 'NEW';
console.log(selectedStatus); // -> [empty string]

And here is the first “clumsiness” (in a way) I was talking about. When you set the type of your variable to a enum (e.g. const selectedStatus: Status ), you need to always use the enum to assing it’s value.

Why? Well a string is not the type of your enum, duh. Of course there is a workaround to this situation

enum Status {
    NEW = "NEW",
    PENDING = "PENDING",
    DONE = "DONE",
  }
  // 👇 We assign our "selected" string to our variable and it's accepted 
  const selectedStatus: keyof typeof Status = 'NEW'; // ✔️
  console.log(selectedStatus); // -> NEW

keyof typeof Status extracts the keys of your Enum and created a union type → "NEW" | "PENDING" | "DONE". This is really helpful because now a simple string with the correct value will be accepted. Also as a union type it will throw errors when the string is not a correct one:

// 🛑 Type '"a"' is not assignable to type '"NEW" | "PENDING" | "DONE"'.ts(2322)
const foo: keyof typeof Status = "a";

A quick stop - why I like Enums

While a lot of people are against Enums and are saying that types and unions make for a better overall codebase… I do find them helpful.

When I go through a code review and I have a complicated or long logic, seeing enums is helpful to keep track on what’s going on. The “clumsiness” from above is exactly the one thing, helping:

// 👇 I don't really need to know the WHOLE type of the object
// using Status.NEW clearly shows me that this accepts a status value
const foo.tickets.status = Status.NEW;

// 👇 It's also helpful if the keys aren't the easiest to understand
// e.g. 'process' is your Jira swimlane 🤷
const foo.process.position = Status.NEW; 

But this also brings a lot of imports - Status needs to always be imported. Yes, it’s not the best, but hey - it’s not completely awful. 🤐

Flexible option 1 - Array

A somewhat more flexible option to the classical TS Enum is using an array:

// 👇 Define your values and "lock" it with "as const"
const statuses = ["NEW", "PENDING", "DONE"] as const;

// Extract the values of the array to a union
type Status = (typeof statuses)[number]; // "NEW" | "PENDING" | "DONE"

// 👇 Use the original array - Intellisense will show hints
const myStatus: Status = statuses[0]; // ✔️ accepted
console.log(myStatus); // -> "NEW"

// 👇 Use a string - Intellisense will show hints
const otherStatus: Status = "NEW"; // ✔️ accepted
console.log(myStatus); // -> "NEW"

This is a pretty neat workflow, because an enum is simply a restricted set of values. If you have a restricted set of values, why not just list them in an array? That’s exactly the idea here. Another additional “feature” is as const - you can read more about it here const assertion , but in short it makes your array readonly and you can not change it later on.

We define our values in an array and then automaticaltly create a union type from that array. This setup is benefitial for couple of points:

  • ✅ You start with an array of the values, so later on in you app if you need to list them or itterate over them - it’s already done!
  • ✅ Intellisense for the union type
  • ❌ You can use the original array to assign a value, but honestly it’s not the most ergonomic or easiest way to keep track. Example: statuses[0] , which will be hinted as (property) 0: "NEW"

Flexible option 2 - Object literals

This method is also touched in the official docs under Objects vs Enums and identical to the previous method, but with a … you guessed it - object.

// 👇 Define your values and "lock" it with "as const"
const statuses = {
  NEW: "NEW",
  PENDING: "PENDING",
  DONE: "DONE",
} as const;

// Extract the values of the array to a union
type Status = keyof typeof statuses; // "NEW" | "PENDING" | "DONE"

// 👇 Use the original object - identical to Enum
const myStatus: Status = statuses.NEW; // ✔️ accepted
console.log(myStatus); // -> "NEW"

// 👇 Use a string - Intellisense will show hints
const otherStatus: Status = "NEW"; // ✔️ accepted
console.log(otherStatus); // -> "NEW"
  • ✅ Accepts both object[key] or a string notation
  • ✅ You start with an object of the values, so later on in you app if you need to list them or iterate over them - you can do it directly!
  • ✅ Intellisense for the union type
  • ✅ Intellisense for the object’s keys - this is now completely the same as the Enum experience. So if you find the Enum approach helpful to keep track - this is probably would be the preferred way.
  • ❌ You can’t create define your object and type with the same name, even if one is capitalized - like the example above. If you like to prefix you types with I ( e.g. IStatus ) then that’s not an issue. And yes, I KNOW this is not an interface… gosh.

You can also use the usual Object methods to access the values, like:

// 👇 Can't create the union type, so keyof typeof is the better way
const foo = Object.keys(statuses) // Type: string[]

// 👇 You can get a union
const bar = Object.values(statuses) // Type: ("NEW" | "PENDING" | "DONE")[]

But let’s be honest - these examples are totally valid for TS Enums too!

A final note on Enum values … why mismatch them?!

// 👇 The key and the value are different
enum Status {
  NEW = "new",
  PENDING = "pending",
  DONE = "done",
}

// 🛑 Type '"new"' is not assignable to type 'Status'.ts(2322)
const foo: Status = "new";
console.log(foo); // -> "new"

// 🛑 Type '"new"' is not assignable to type '"NEW" | "PENDING" | "DONE"'.
// Did you mean '"NEW"'? ts(2820) But ... "NEW" !== "new" 🤷
const bar: keyof typeof Status = "new";
console.log(bar); // -> "new"

// 👇The only accepted case now
const zoo: Status = Status.NEW;
console.log(zoo); // -> "new"

I’m not saying this is bad, I just very rarely encounter such situation and really try to avoid it. When you have a “mismatched” Enum is even harder to try and accept strings or other values. I’m saying harder, because I really try to not use the casting operator, like const foo: Status = "new" as Status; .

Again, in this example maybe it doesn’t make that much sense, but start thinking about how you’re working with such a field. When you get your data from an API response or some kind of form/ui component this becomes … clumsy.

Another final note - A “real life” example of TS Enum pain

This example is citated from a comment by Dmitry Scheglov (Dec 27 '22) in Typescript Enums are bad!!1!!!1!!one - Are they really? . I really like this example, because while I can’t swear by it … I’m pretty sure something like this made me loose a day of sleep in an older project.

Well, we met the problems with enums when we started using automatic type generation from GraphQL-schema. … Two enums separaterly declared couldn't be substituted one with another even if they have the same list of pairs name -> value

enum EFFECT1_FAILURE_CODE {
  ERROR_1 = 'EEFF_ERROR_1',
  ERROR_2 = 'EEFF_ERROR_2'
}

enum EFFECT2_FAILURE_CODE {
  ERROR_1 = 'EEFF_ERROR_1',
  ERROR_2 = 'EEFF_ERROR_2',
}

declare function f(failure: EFFECT1_FAILURE_CODE): void;

function g(failure: EFFECT2_FAILURE_CODE) {
  f(failure);
  // Argument of type 'EFFECT2_FAILURE_CODE' is not
  // assignable to parameter of type 'EFFECT1_FAILURE_CODE'
}

So, on the application level we had to refactor our enums to the UNIONS. And we re-configured typegeneration to emit the unions for GQL enums. And that's it -- everything works.

const EFFECT1_FAILURE_CODE = {
  ERROR_1: 'EEFF_ERROR_1' as const,
  ERROR_2: 'EEFF_ERROR_2' as const,
}

type EFFECT1_FAILURE_CODE = typeof EFFECT1_FAILURE_CODE[keyof typeof EFFECT1_FAILURE_CODE];

const EFFECT2_FAILURE_CODE = {
  ERROR_1: 'EEFF_ERROR_1' as const,
  ERROR_2: 'EEFF_ERROR_2' as const,
}

type EFFECT2_FAILURE_CODE = typeof EFFECT2_FAILURE_CODE[keyof typeof EFFECT2_FAILURE_CODE];

declare function f(failure: EFFECT1_FAILURE_CODE): void;

function g(failure: EFFECT2_FAILURE_CODE) {
  f(failure); // Ok!
}

So what now?

Are TypeScript Enums bad? Meh, not nececarily. Sometimes, often or not, they create situations that you need to work around them instead with them. And that’s a thing a don’t really like. I’m lazy and I like when my tooling is easy - that’s why I’m starting to use and depend on the last 2 options more and more.

Do you need to use these ways? No, use whatever you like. But at least think about it when you want to have a little bit more flexible code base.

Resourcess and homework

A simple google search will show you a lot of results like Alternatives to TypeScript enum , Tidy TypeScript: Prefer union types over enums , Why TypeScript enums suck , and many more. An article that’s PRO enums and I liked is Typescript Enums are bad!!1!!!1!!one - Are they really? . All of these are good takes and I would advise on checking them out, as each of them will show a different take and examples on somewhat the same topic.