~/blog/enum-vs-union|
Scan this vCard to save my contacts
#frontend

TypeScript Enums vs Union Types

October 13, 2023 · 10 min read

Table of Contents

Preface

The question of whether union types or enums are better may seem endless in the TypeScript community. There are plenty of reasons to use one over the other, but let's take a pragmatic point of view.

With experience, I have noticed that I'm more interested in solutions that are understandable, explainable, and maintainable rather than easier to use. Especially if these solutions may limit my options in the future and force me to make compromises.

If a developer prefers to use unions or enums solely based on personal preference or experience in other languages, it's time to look at this question from a more practical perspective.

Disclaimer

I'm not a hater of either of these two approaches. As mentioned above, the chosen approach must be intuitive, scalable, and maintainable. If this post doesn't help you decide, take a look at these resources:

Enums and Union Types

First, let's refresh our knowledge of these two TypeScript entities.

// This is an Enum
enum APIErrorEnum {
  // Authorization failure
  Unauthorized = 1,
  // User doesn't have access to the resource
  AccessDenied = 1000,
  // Wrong input data provided
  WrongDataProvided = 2000,
}
// This is a Union Type
type APIErrorUnion = 1 | 1000 | 2000;

At first glance, using enums may seem more descriptive and less error-prone because each enum member has a name and can even have a meaningful description. But is this enough to choose enums over union types?

Compatability betweer Enums and Union types

Sometimes a few lines of code are better than long discussions. Let's extend the examples shown above and manipulate with types.

// Union types
// ✅ Literal assignment
const unionErr: APIErrorUnion = 1;
// ✅ Enum to Union assignment
const unionErr: APIErrorUnion = APIErrorEnum.Unauthorized;

// Enums
// ✅ Assignment
const enumErr: APIErrorEnum = APIErrorEnum.Unauthorized;
// ✅ Literal assignment works fine and is type-safe since TypeScript v5
const enumErr: APIErrorEnum = 1;

// Cross assignment
// ✅ Enum to Union
const enumToUnionErr: APIErrorUnion = enumErr;
// ✅ Union to Enum
const unionToEnumErr: APIErrorEnum = unionErr;

So far, everything is fine, and there don't seem to be any differences. Let's try using strings.

enum StatusEnum {
  Pending = 'pending',
  Success = 'success',
  Failure = 'failure',
}

type StatusUnion = 'pending' | 'success' | 'failure';

// ⛔️ Type '"pending"' is not assignable to type 'StatusEnum'.(2322)
const status: StatusEnum = 'pending';

// ✅ Works fine in reverse
const status: StatusUnion = StatusEnum.Pending;

Moreover, two different enums with identical sets of members are, in reality, two different types and are not interchangeable:

enum APIStatusEnum {
  Pending = 'pending',
  Success = 'success',
  Failure = 'failure',
}

// ⛔️ Type 'APIStatusEnum.Pending' is not assignable to type 'StatusEnum'.(2322)
const status: StatusEnum = APIStatusEnum.Pending;

We can't assign string literals to string enums or interchange two enums with the same set of members because enums generally have stricter types.

The same is true about assigning string unions to enums:

type StatusUnion = 'pending' | 'success' | 'failure' | 'unknown';

// ⛔️ Type 'StatusUnion' is not assignable to type 'StatusEnum'.
const status: StatusEnum = 'success' as StatusUnion;

Imagine having automatically generated TypeScript types from an OpenAPI schema where instead of union types, the script generates enums. In such a case, straight assignment won't be possible:

type GeneratedAPIUserResponse {
  first_name: string,
  last_name: string,
  gender: GeneratedAPIUserGenger,
}

enum GeneratedAPIUserGenger {
  Male = 'male',
  Female = 'female',
  Other = 'other'
}

// Client-side type
type UserEntity = {
  name: string;
  // Union
  gender: 'male' | 'female' | 'other';
}

function userTransformer(data: GeneratedAPIUserResponse): UserEntity {
  return {
    name: `${data.first_name} ${data.last_name}`,
    // ⛔️ Oopsie
    gender: data.gender
  }
}

As you may have already understood, the exact same type error will arise if we change the enum GeneratedAPIUserGender to be a union type and UserEntity to be an enum.

The conclusion we can draw from this is that enums and unions cannot coexist with each other.

Extendability

Perhaps this is a contrived example, but what if it's necessary to extend one of the existing types (temporarily, for example)?

// ✅ Union extension
type ExternalAPIErrorUnion = 10000 | 10001;
type ExtraAPIErrorUnion = APIErrorUnion | ExternalAPIErrorUnion;

const extraErr: ExtraAPIErrorUnion = 10001;

// ⛔️ Oopsie
// enum ExternalAPIErrorEnum extends APIErrorEnum {
//   Timeout = 10000,
//   EmptyBalance = 10001
// }

We can't extend enums in a traditional way, although there is a technique I find a bit weird:

enum ExternalAPIErrorEnum {
  Timeout = 10000,
  EmptyBalance = 10001,
}

type APIError = APIErrorEnum | ExternalAPIErrorEnum;

// It works, but now there's a third type (union), and we have to use a combination of all three types across the codebase.
const err: APIError = ExternalAPIErrorEnum.Timeout;

Iterable Types

One of the benefits of enums over union types is that under the hood, they are real JavaScript objects and can be iterated through with a loop or converted to an array using, for instance, the Object.entries() method.

StatusEnum, at the end of the day, will be compiled into the following structure:

var StatusEnum;
(function (StatusEnum) {
  StatusEnum['Pending'] = 'pending';
  StatusEnum['Success'] = 'success';
  StatusEnum['Failure'] = 'failure';
})(StatusEnum || (StatusEnum = {}));

Yes, it adds extra code to the resulting bundle, but let's not ignore this fact. I believe that an increase of several kilobytes isn't something crucial to the performance or bundle weight to abandon enums.

What's even more interesting is that it's possible to iterate over our enum:

typescript

for (const [k, v] of Object.entries(StatusEnum)) {
  console.log(k, v);
}

To achieve the same benefit using union types, we have to declare an array first:

const statuses = ['pending', 'success', 'failure'] as const;

type StatusUnion = (typeof statuses)[number];

I personally find this approach clearer, despite the fact that it may initially seem more verbose. We have an array to iterate through, and a type to use in place of types. At the same time, they are always in sync, and the types match.

type StatusProps = {
  status: StatusUnion;
};

const StatusComponent: FC<StatusProps> = ({ status }) => <div>{status}</div>;

const StatusList = () => (
  <Fragment>
    {statuses.map((status) => (
      // Works perfectly
      <StatusComponent status={status} />
    ))}
  </Fragment>
);

Other Nuances of Enums

  1. Numeric enums get a reverse mapping
  2. const enums are not iterable at runtime because they are completely removed during compilation
  3. An object with as const maybe a sufficient solution in most cases

With enums, it's still possible to do some interesting things, but these cases are relatively rare, I would say.

enum MessageFlag {
  Sent = 1 << 1,
  Read = 1 << 2,
  Spam = 1 << 3,
  ReadSpam = Read | Spam,
}

type Message = {
  state: number;
};

const msg: Message = { state: 5 };

if (msg.state & MessageFlag.ReadSpam) {
  // Do something
}

Conclusion

I consider myself a pragmatic developer. The tools I use must help me solve the problems I encounter and build the things I want to create. If they create additional obstacles and complicate things, forcing me to find workarounds, it may be better to stick with something else.

As previously mentioned, every choice we make should have a reason, and my choice for today is to use Union Types as much as possible. This doesn't mean I hate Enums; on the contrary, I like them as an idea and because they are more strict. However, in my daily software development work, it's easier to stick with Union Types.