Skip to content

Can you make safe enums in JavaScript?

by Liam Mills-Tait

This post shows a range of approaches to defining enum-like structures in JavaScript. The simple fact is that JavaScript does not have enums (yet). We are hacking away to get a feature of other languages JavaScript does not have.

JavaScript does not have enums, so this post's title is a trick question and the answer is NO.

So if JavaScript doesn't have enums, we should probably define what we are looking for, so we know what to build. I willl skip type unions as this a quite the stretch from no enums

  • Enumeration define a fixed number of constants
  • Uniqueness prevent any potential clashes or duplicate values.
  • Immutability cannot be modified or re-assigned. Once created, they retain their identity throughout the code execution.

Enums in TypeScript are a widely covered topic. The generally accepted advice is that TypeScript Enums are not JavaScript, they were a mistake, and you should not use them. This is detailed in Object vs Enums in the TypeScript docs, and many videos and blog posts. If you want to use TypeScript enums, this post doesn't cover them.

String type unions are a common option, and one I quite enjoy. There are benefits to centralising enum defintion, we will cover those apporaches. JavaScript replacements for TypeScript enums.

Our starting point is the Plain Old JavaScript Object (known as the acronym POJO)

const LogLevel = {
  Debug: "DEBUG",
  Warning: "WARNING",
  Error: "ERROR",
} as const;
 
type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];

This is a nice drop in JavaScript replacement for a TypeScript enums.

  • Enumeration βœ…
  • Uniqueness ❌
  • Immutability 🀷 guaranteed at build-time, so it depends

Object.Freeze instead of as const

We want our enums to be immutable, so we put as const on our object to ensure our object is not modifed.

As you hopefully know, const is not constant in JavaScript πŸ™ˆ, so we use as const πŸ™‰ to keep a const constant for real this time πŸ™Š

Let's take a look at one of the main often quoted reasons for not using TypeScript Enums and using a POJO.

  1. Emit clean, idiomatic, recognizable JavaScript code.
    β€” TypeScript Design Goals

TypeScript enums breaks this principle, but having to compiles to something not recognizable. JavaScript doesn't have enums, so there if TypeScript wants to support enums, they cannot only be a type, code must be generated.

TypeScript provides better type-checking, but it still compiles to JavaScript and does not enforce the same level of safety at runtime. Why use a TypeScript feature when we can use a JavaScript feature and get build-time and runtime safety around modification of enums.

The alternative is to use [Object.freeze](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze In standard JavaScript, you can use Object.freeze(obj) to create read-only objects and prevent modifications. By freezing an object representing your enum-like structure, you can enforce immutability and prevent accidental modifications.

const LogLevel = Object.freeze({
  Debug: "DEBUG",
  Warning: "WARNING",
  Error: "ERROR",
});
 
type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];

By using Object.freeze(), you ensure that the properties of the object cannot be modified after it's defined. This helps in preventing unintended changes to the enum-like structure. TypeScript understands what Object.freeze does, so our types are very similar to our as const version.

  • Enumeration βœ…
  • Uniqueness ❌
  • Immutability βœ…

Symbol instead of string

While these approaches can emulate enums in JavaScript, they lack some of the strict type-checking and safety features found in languages with built-in enum support. There is still something strange about our enums so far. They are not unique. An enum can equal another enum, which can equal a stringβ€½

Using the example of two enums that happen to match. Let's say you are writing a linter. You define log levels like before, and also a lint level.

Is it accurate to say that LogLevel.Warning is the same as LintLevel.Warning. Should they both be equal to "WARNING"

const LogLevel = Object.freeze({
  Debug: "DEBUG",
  Warning: "WARNING",
  Error: "ERROR",
});
type LogLevel = (typeof LOG_LEVEL)[keyof typeof LOG_LEVEL];
 
const LintLevel = Object.freeze({
  Warning: "WARNING",
  Error: "ERROR",
});
type LintLevel = (typeof LintLevel)[keyof typeof LintLevel];

They both have a WARNING and ERROR value. let's say we had two functions log and lint

function log(level: LogLevel) {}
function lint(level: LintLevel) {}
 
log(LintLevel.Warning);
lint(LogLevel.Error);

This is completely fine, our program runs, but is it really a valid state. Are these the same values, or do they just happen to have the same name?

How can we ensure the enums are unique?

Symbol to the rescue. Symbols are guaranteed to be unique, so we can use them as our enum value

const LogLevelDebug = Symbol("Debug");
const LogLevelWarning = Symbol("Warning");
const LogLevelError = Symbol("Error");
 
const LogLevel = Object.freeze({
  Debug: LogLevelDebug,
  Warning: LogLevelWarning,
  Error: LogLevelError,
});

There is a trade-off here, you will have write a function to parse strings and convert these symbols into strings, if required. You will also no longer be able to use the string values directly, which can be seen as positive or negative. Centralising enum definition is certainly good, but it is nice to not have to import an enum to use a function.

  • Enumeration βœ…
  • Uniqueness βœ…
  • Immutability βœ…
  • Extra code for parsing, mapping to a string βœ…

If the JavaScript Records & Tuples Proposal is accepted we will be able write

const LogLevel = #{
  Debug: Symbol("Debug"),
  Warning: Symbol("Warning"),
  Error: Symbol("Error"),
}

Thanks for reading, happy coding!

Conclusion

While JavaScript lacks native enum support, we can use techniques like freezing objects and symbols to emulate enum-like behavior. These approaches provide varying runtime safety, immutability and uniqueness. Understand these workarounds and choose an approach that fits your needs.