enum, const enum, as const - Enumeration in TypeScript

Finding Ways to Enumerating Data

In a recent project rewrite, many states need management. To avoid hard-coded values and ensure clarity, I document some findings. For example, there is currently a warning level data:

const LogLevel = {
DEBUG: 'DEBUG',
WARNING: 'WARNING',
ERROR: 'ERROR',
};

If I need to use or receive this data in different parts of the project, how can I ensure that I rely on a single source of truth for this data?

// I only want this function to receive LogLevel data, not any other type
function doSomeThing(level: unknown) {
// Do Something
}

Using Enumerations enum

Since this is a TypeScript project, my first thought was to use enumerations (Enums) to manage the data. Enums are a special “non-type-level” feature of TS used to represent a set of constants (immutable values). There is some strange magic involved that not everyone likes, for example, a simple enum:

enum LogLevel {
DEBUG,
WARNING,
ERROR,
}

The actual compiled output will look like this:

var LogLevel;
(function (LogLevel) {
LogLevel[(LogLevel['DEBUG'] = 0)] = 'DEBUG';
LogLevel[(LogLevel['WARNING'] = 1)] = 'WARNING';
LogLevel[(LogLevel['ERROR'] = 2)] = 'ERROR';
})(LogLevel || (LogLevel = {}));

This results in an object that looks like this:

const LogLevel = {
DEBUG: 0,
0: 'DEBUG',
WARNING: 1,
1: 'WARNING',
ERROR: 2,
2: 'ERROR',
};

The above is Numeric Enums🔗, so usually, String Enums🔗 are used:

enum LogLevel {
DEBUG = 'DEBUG',
WARNING = 'WARNING',
ERROR = 'ERROR',
}
function doSomeThing(level: LogLevel) {
// Do Something
}
doSomeThing(LogLevel.DEBUG);

This means that the Enums type is unique; even if there is another LogLevel defined with the same type, it will still be considered a different type. This syntax enhances the typically Structural Type characteristics of TS to a Nominal Type system characteristic. Behind the scenes, TS uses some magical abstraction but is essentially wrapping a JS object.

  1. I don’t really like some of the unpredictable magical transformations in TS (if values are not defined, Numeric Enums will be created, which is usually undesirable).
  2. The characteristics of the type system are slightly different, but I don’t think it’s a big issue.
  3. The TypeScript Team discussed that if they could do it over, they probably wouldn’t add this feature🔗

Using const enum

This approach allows TypeScript to only handle the values of the enumeration in terms of types, meaning no JS will be generated after compilation. This sounds clean and intuitive! TS will directly replace the places where const enum is used with the corresponding values at compile time. However, in the TS official documentation🔗, it is generally not recommended to use const enum, as using it in a shared codebase can lead to issues if the compiler cannot be controlled.

const enum LogLevel {
DEBUG,
WARNING,
ERROR,
}

Using as const

After going around in circles, it turns out the best method is right at our feet?? Introducing the old JS object POJO (Plain Old JavaScript Object), which is essentially a JS object that uses as const to inform TS that this object is completely immutable, similar to Object.freeze()🔗, but in a deep sense and not existing at runtime, truly immutable values.

If you’re not familiar with JS characteristics, you might think that JS’s const means declaring a variable whose content cannot be changed, but in reality, the immutability here means that the variable cannot be assigned a new memory address. Because of this characteristic, TS cannot determine whether a variable declared with const is an immutable value.

const logLevel = {
DEBUG: 'DEBUG',
WARNING: 'WARNING',
ERROR: 'ERROR',
};
logLevel.DEBUG = 'WHATEVER'; // Can be reassigned
// Type:
// const logLevel: {
// DEBUG: string;
// WARNING: string;
// ERROR: string;
// }

But when we use as const in TS, it truly lets TS know that it is immutable, allowing us to obtain a more precise type:

const logLevel = {
DEBUG: 'DEBUG',
WARNING: 'WARNING',
ERROR: 'ERROR',
} as const;
// Type
// const logLevel: {
// readonly DEBUG: "DEBUG";
// readonly WARNING: "WARNING";
// readonly ERROR: "ERROR";
// }

Great! This means we can use keyof and typeof to obtain the type of this object and apply it anywhere:

type LogLevel = (typeof logLevel)[keyof typeof logLevel];

We can even create a utility type to help us with the conversion:

type ObjectValues<T> = T[keyof T];
const logLevel = {
DEBUG: 'DEBUG',
WARNING: 'WARNING',
ERROR: 'ERROR',
} as const;
type LogLevel = ObjectValues<typeof logLevel>;
function doSomeThing(level: LogLevel) {
// Do Something
}
doSomeThing('DEBUG');

Choices

The arguments against using Enum can be summarized as follows:

  • The results after compilation can be a bit strange, and these strange points require special observation of the compilation results or reading the documentation to understand.
  • Enums are more rigid in usage (must pass in Enum as a value, compared to as const, which only requires passing in the corresponding value).

However, I believe Enums also have their advantages:

  • Enum names and purposes are very clear.
  • Very rigid, all values can only be input through Enum, ensuring data correctness.

Using as const entirely to achieve enumeration data management is more intuitive, with less cognitive burden, and is more flexible.

Further Reading