loading...
Cover image for Class-based enums in Typescript: Are they worth the trouble?

Class-based enums in Typescript: Are they worth the trouble?

tjfroll profile image TJF ・5 min read

One of Javascript’s most glaring omissions is first-class support for enums. Anyone who’s spent time in other languages knows the value of these simple structures, so it’s no surprise that one of Typescript’s few language additions is the enum. But Typescript’s implementation is quite basic — under the hood, they’re just objects, and this presents two significant pain points.

Problem 1: Iteration over Typescript enums requires converting to Array

This might seem like a nitpick since ES6 gave us Object.values — but if we consider the most common use cases for enums, there’s a constant need for iteration. Converting every time we need to populate a list or dropdown is a nuisance, but there’s a hidden cost: the resulting type is no longer an enum, but a string. This quickly leads to situations where string values taken directly from an enum won’t be accepted anywhere we expect an enum.

enum Bah { ... };
const humbug = (bah: Bah) => {};
const bahValues = Object.values(Bah);

// Error: Type 'string' is not assignable to type 'Blah'
humbug(bahValues[0])

Even if we try to annotate upstream, the problem persists.

// Error: Type 'string' is not assignable to type 'Bah'
const bahValues = Object.values<Bah>(Bah);
const bahValues: Bah[] = Object.values(Bah);

Our only option is to cast or assert, which defeats the purpose of working with strong types and creates unhelpful noise in our code.

Problem 2: Typescript enums can’t be extended

Enums in Python or Java are classes, which allows for custom attributes and methods directly on the enum. Some code philosophers argue this goes against the ethos of enums, which are meant to be static lists and nothing more. In my experience, however, enums don’t live in isolation from changes in the application, and they’re rarely static. Consider a few common requirements that any application might present:

  • Define a static sort order for iteration/display
  • Custom toString for localization or business logic
  • Deprecating values without deleting
  • Static subsets of values

Class-based enums make it possible colocate these features with the enum itself. Classes may have fallen out of vogue in the shift to functional-reactive styles over the last few years, but this is a situation where classes could offer the more declarative approach. How might we accomplish this in Typescript?

Writing a class-based enum in Typescript

Let’s start with the code, and then walk through its features.

export class Priority {
  static asArray: Priority[] = [];

  // Values
  static readonly CRITICAL = new Priority('CRITICAL');
  static readonly HIGH = new Priority('HIGH');
  static readonly MODERATE = new Priority('MODERATE');
  static readonly MEDIUM = new Priority('MEDIUM', true);
  static readonly LOW = new Priority('LOW');'

  // Subsets
  static readonly GENERATES_WARNINGS = [
    Priority.CRITICAL,
    Priority.HIGH,
  ];

  static readonly ACTIVE = Priority.asArray
    .filter(({ deprecated }) => !deprecated);

  constructor(
    public readonly value: string,
    public readonly deprecated = false,
  ) {
    Priority.asArray.push(this);
  }

  valueOf() {
    return this.value;
  }

  toString() {
    return someLocalizationFunction(this.valueOf());
  }

  get order() {
    return Priority.asArray.indexOf(this);
  }
}

First, we define the static collection asArray, as this needs to be instantiated before any values can be added. Next, we create our Priority enums. Take note that MEDIUM uses a second argument of false to designate itself as deprecated. If we look ahead to the constructor, we see that deprecated is defaulted to false for other enums, and each new Priority is getting added to the static asArray collection. After the individual values are created, we can create arbitrary subsets of values manually or by using other properties of the enum.

Lastly, we have our accessors. Using valueOf() and toString() provides a consistent interface with ECMAScript’s objects and strings. For our order getter, we’re able to rely on the definition order of the values themselves (represented in asArray), which provides a simple mechanism to define sort order.

This gives us everything we need to start using our new enum class just like we would a Typescript enum:

class ErrorMessage {
  constructor(public priority: Priority) {}
}

const criticalMessage = new ErrorMessage(Priority.CRITICAL);
const allErrors = Priority.asArray.map(ErrorMessage);
const warnings = Priority.GENERATES_WARNINGS.map(ErrorMessage);

This looks great! We’ve solved for many common use cases and preserved type safety. But has this been worth all the effort?

Class-based enums have significant drawbacks

There are some issues with our implementation.

As soon we start creating more enums, we’ll find ourselves attempting to factor out the common operations — but this proves to be challenging. We could make a base Enum class and move some functions like toString() and valueOf(). However, all of our static members are specific to each enum, and can’t be abstracted away. Type definitions also can’t moved to the base class, as we would need to use generics — but generics cannot be applied to static members. The end result is that even with some clever abstraction, there will still be a lot of duplicated code with each new enum.

Another problem is that these enums require instantiation. If we’re ingesting raw data from an external source — say, some JSON with a property we want to annotate:

interface PrioritizedError {
  error: {
    priority: Priority
  }
}

const errorData: PrioritizedError = {
  error: {
    priority: 'CRITICAL' // Invalid type
  }
}

We can’t annotate errorData with our PrioritizedError interface as-is. We would first have to transform this data to ensure that error.priority gets instantiated with our Priority enum.

const originalData = require('error.json');
const transformedData: ExternalError = {
  error: {
    priority: Priority[originalData.error.priority],
  }
};

This creates a gap between the original data and the data used by the application. We face the reverse issue anywhere we might be sending data to an external source, requiring another transformation back into string format. This introduces additional layers in a pipeline that might otherwise have been seamless. Every time we touch data is another opportunity for bugs and corruption.

This transformation issue isn’t just isolated to file read/writes or API requests. Third-party libraries won’t accept our enums, so we may have to transform back-and-forth within individual components. It’s these handoffs that are particularly dangerous, as external dependencies might not warn us when we’ve failed to provide data in the expected format.


So, are class-based enums worth the effort? As with most things, I think the answer is a rock-solid “it depends”.

These implementations are certainly not optimal — I’m sure there’s plenty that could be improved on, leveraging some more advanced features in Typescript. Some of these improvements might properly address scalability / DRY issues. Still, the decision mostly comes down to your application’s needs.

If you find that some of your enums tend to come with tightly-coupled business logic or you need a structure that flexibly supports additional properties and metadata, this may be a useful pattern. But if you just want easy iteration and don’t need any custom methods, class enums are probably overkill. I would exercise particular caution in situations where it becomes necessary to add new transformations.

Discussion

pic
Editor guide