All Articles

Why You Should Use String Literal Unions Over Enums in TypeScript

Joe PreviteJoe Previte

In this article, we’ll explain why you should use unions over enums in your codebase. We’ll even include TypeScript playground links for our examples. This way, you can quickly try them yourself. By the end, you’ll be prepared to convince your team why the right move is unions over enums.

Understanding Both Sides

“Seek first to understand, then to be understood.”
— Stephen R. Covey, The 7 Habits of Highly Effective People

If you want to convince your team to adopt one pattern over another, you need to understand both sides. This gives you the strongest position possible. We’re going to do that with today’s topic by practicing steelmanning. First, we’ll argue for enums, then we’ll argue for unions, specifically string literals.

By the end of it, you’ll know the strongest arguments for both sides and be able to convince your team which pattern to adopt and why. Let’s jump in!

💡Tip

We assume you’re familiar with unions and enums. If you aren’t, read up on those first.

Show me the code

If you want to jump straight into code examples, take a look at these TypeScript playground links:

Arguments in favor of Enums

Although I’m not in the enum camp, these are some of the strongest arguments I’ve heard from enum supporters.

Enums are quick to refactor

As Joel points out, most IDEs have a way to rename a symbol (like an Enum) and it will automatically do it throughout your entire codebase.

💡Tip

We’re strictly comparing unions of string literals to string enums but do know that this type of refactoring work for objects so something to keep in mind.

Enums can be marked as deprecated

Wes Souza shared a fantastic example of this on Twitter:

As you can see the banana is annotated with TSDoc as @deprecated and IDEs will then strikethrough this so you know it’s deprecated.

Enums are helpful when value is not self-explanatory

Sometimes strings aren’t self-explanatory and need some type of label. Enums work well for this.

enum Smiley {
  Happy = ":)",
  Sad = ":(",
}

h/t to Pedro Figueiredo for mentioning this.

Enums work well for logical references

One of the most common use cases for enums is log levels. For instance, VS Code defines this:

/**
 * The severity level of a log message
 */
export enum LogLevel {
  Trace = 1,
  Debug = 2,
  Info = 3,
  Warning = 4,
  Error = 5,
  Critical = 6,
  Off = 7,
}

This might be more difficult or less readable using a union of string literals.

Arguments in favor of Unions

Below are some of the strongest arguments I’ve found from those who believe unions should be used in place of enums.

No code emitted

TypeScript is often described as “syntax for types” meaning it disappears after it’s compiled to JS. Enums break that rule. They can be compiled to JS, thus increasing the bundle size.

However with a union of string literals, no code is emitted aka no increase in bundle size. This means you can have a long list of string literals in a union without an increase in bundle size.

h/t to Sasha Koss for the example.

Strings are native to JS - Enums aren’t

Learning TypeScript is tough. There are a lot of TypeScript-specific things you end up having to learn. And that adds to the amount your team has to learn. If you stick with a union of string literals, you reduce the need to know enums.

No need to remember any knowledge about enums and their quirks. And, your codebase stays closer to JS.

String literal unions can be used directly

When you use a string literal union, it just works. With an enum, you are locked into using that throughout your app - even if the enum is technically the same value as your string. This can cause issues with things that may come from external sources i.e. API responses and you suddenly have to cast to enums manually.

Here is an example which demonstrates string literals “just working.”

enum DirectionEnum {
  Up = "up",
  Down = "down",
  Left = "left",
  Right = "right",
}

type DirectionLiteral = "up" | "down" | "left" | "right";

function fnWithEnum(dir: DirectionEnum) {}
function fnWithLiteral(dir: DirectionLiteral) {}

const a = "up";

fnWithLiteral(a); // just works

// Argument of type '"up"' is not assignable to parameter of type 'DirectionEnum'.
fnWithEnum(a); // BOOM, now you need use the enum directly, typecast or some other approach…

h/t to Fabien Bernard for this great example.

No need to duplicate key & value

Oftentimes when you use enums you have to duplicate the key and the value. With a union, you don’t:

enum EventEnum {
  SEND = "SEND",
  RECEIVE = "RECEIVE",
}

type EventString = "SEND" | "RECEIVE";

String literal unions save you a lot of duplication and a lot of keystrokes.

No imports needed

Enums have to be used directly whereas unions work without imports. See this example:

// fileA.ts
export enum EventEnum {
  SEND = "SEND",
  RECEIVE = "RECEIVE",
}

export type EventString = "SEND" | "RECEIVE";

export function logEvent(event: EventString) {
  console.log(`type of event: ${event}`);
}

export function logEventEnum(event: EventEnum) {
  console.log(`type of event: ${event}`);
}

// fileB.ts
import { EventEnum, logEvent, logEventEnum } from "./other";

// no import needed
logEvent("SEND");

// can't use string directly
// import needed
logEventEnum("SEND");
logEventEnum(EventEnum.SEND);

Runtime and compile time access to list

Depending on how you define your union of string literals, you may be able to get both runtime and compile time access. Here is an example where we define directions as a const array of string literals. We then derive the type from it and have access at runtime:

const directionsAsString = ["up", "down", "left", "right"] as const;
// union derived from runtime value
type Direction = typeof directionsAsString[number];
//   ^? type Direction = "up" | "down" | "left" | "right"
console.log(
  `The following are valid directions to move your player: ${directionsAsString
    .join(", ")
    .trim()}`
);

Notice how they can also be joined easily because I used a runtime value to create my type value. Win-win!

Resources

In case you didn’t realize, this is a spicy topic 🌶️. People have strong opinions about this topic. We’ve only touched the surface; we only covered string enums vs string literal unions. Here are some articles to read if you want to continue down this rabbit hole:

Which to use?

I side more with the union folks. I like the refactoring and the ability to use deprecated warnings with enums but those are things I rarely reach for. My favorite feature of unions is deriving the type from a runtime value. It feels it’s closer to JS and I prefer that.

With all “best practices” the most important thing you can do for your team is choose what best fits your codebase. You now have the best arguments for both sides which means you’re well-equipped. Now consider the tradeoffs for reach, choose your side and make your case. Good luck!

Thank you to TypeScript Community

I want to thank everyone who responded to my tweet when I asked for help arguing for string literals [unions]. This couldn’t have been written without you!

Also, special thanks to Cody, Alexey, and Simon for providing feedback on the first draft.