Deep dive into enums, const assertions and union types

Aishwaryalakshmi Panneerselvam
10 min readJun 6, 2021

--

Typescript is a useful value addition to the JavaScript world. JavaScript can produce unexpected bugs at runtime because of the flexibility it offers. Typescript helps to catch these bugs at an early stage and save us from such headaches. Introducing this additional complexity with static typing does have enormous benefits when used in the right way.

By the right way, I mean, Typescript come with several sources of “unsoundness” with features like any, unknown, type assertionsetc. It is necessary to understand how the types work internally to use them effectively.

In this blog, I will talk about enums, const assertions and union types and the concepts behind each of these.

Enums

In general, enums can be of two types: string and numeric enums.

String enums

The enum is like an object with keys whose values cannot be altered once they are defined. The string enum syntax looks like this.

enum Direction {   Up = "UP",   Down = "DOWN"}

This generates the following code at runtime in Javascript

var Direction;(function (Direction) {Direction["Up"] = "UP";Direction["Down"] = "DOWN";})(Direction || (Direction = {}));

Let’s look at an example to understand its usage:

Here, processDirection accepts an argument direction of type Direction with two possible values: Direction.Up and Direction.Down and returns the corresponding function based on the value.

Now, if you want to add a new property Left to Direction and if you do Direction.Left = "LEFT", Typescript would throw an error Cannot assign to 'Left' because it is a read-only property.

However, you can extend the Direction type by re-assigning its value with:

enum Direction {    Left = "LEFT"}

Now, the enum would have Up, Down and Left keys.

Another important thing to note is: The string enums adhere to nominal typing, which means if you call the processDirection function with the value UP directly, it would throw an error Argumet of type “UP" is not assignable to parameter of type ‘Direction’.

processDirection("UP") // Type error

Typescript, in general, follows structural typing, where string enums are an exception following nominal typing.

Structural and nominal typing

Structural typing means I care only about the shape of the type. Consider this example:

This is perfectly type-safe in the Typescript world because of structural typing. It means the normalize function accepts a value of type Vector3D and is passed over to the calculateLength function whose argument is of type Vector2D. Although the types differ, Typescript checks only the object shape. That is, whether calculateLength receives an object with keys x: number and y: number. It does not care if the object's type is Vector2D or Vector3D.

Whereas nominal typing is the opposite which means “Ensuring the types are also same”. In the enum example, Typescript expects a value of the exact type Direction instead of just checking its value UP.

Numeric enums

Numeric enums can be with and without initialized values.

Let’s look at without initialized values example first:

enum FileMode {   Read,   Write,   ReadWrite}

This generates the following code:

var FileMode;(function (FileMode) {    FileMode[FileMode["Read"] = 0] = "Read";    FileMode[FileMode["Write"] = 1] = "Write";    FileMode[FileMode["ReadWrite"] = 2] = "ReadWrite";})(FileMode || (FileMode = {}));

If you try to print the value of FileMode on the console, it would be

The numeric enum compiles to an object that stores both key -> value and value -> key mappings. That is, you can access the value either using FileMode.Read or with FileMode[0]. So, the FileMode.Read gives the value 0 and FileMode[0] gives value Read. The string enums do not have this reverse mapping feature.

The numeric enums have the auto-incrementing feature as well.

enum FileMode {  Read = 1,  Write,  ReadWrite}

This produces the output

(function (FileMode) {    FileMode[FileMode["Read"] = 1] = "Read";    FileMode[FileMode["Write"] = 2] = "Write";    FileMode[FileMode["ReadWrite"] = 3] = "ReadWrite";})(FileMode || (FileMode = {}));

So, with Read initialized to 1, the Write and ReadWrite values are automatically assigned to 2 and 3 respectively.

The enums need not be sequential, You can have the following as well.

enum ShirtSize {  XS = 28,  S = 30,  M = 32,  L = 34}

You can also have a combination of both string and number values within the same enum. But, I advise you not to do it as it leads to more confusions.

Although enums are powerful with many features, they have some pitfalls as well. The numeric enums, for example, are not type-safe.

function calculatePrice(size: ShirtSize, basePrice: number) {     const priceMultiplierMap = {        [ShirtSize.XS]: 1,        [ShirtSize.S]: 1.5,        [ShirtSize.M]: 2.1,        [ShirtSize.L]: 2.5     }     return basePrice * priceMultiplierMap[size];}calculatePrice(ShirtSize.M, 35.5) 
// This is type safe and produces correct output as 74.55

However, let’s consider another function call:

calculatePrice(20, 44)

Ideally, in this case, Typescript should throw an error that Argument of type "20" is not assignable to parameter of type "ShirtSize". But it does not, and at runtime, the above function call returns NaN. We use Typescript to catch these kinds of errors before. But the numeric enums completely dissolve the confidence that Typescript offers. If you wish to have constants with numbers, use const assertions.

Another problem with enums is the ugly code that it generates at runtime. When the application is huge with a large number of enums, this would bloat the file size unnecessarily.

To avoid this problem, Typescript has something called const enums which we will see in the next section.

Const enums

Const enums do not have any generated code at runtime in JavaScript.

const enum Directions {   Up = "UP",   Down = "DOWN"}const currentDirection = Direction.Up;

The above code removes all enum declarations and produces only the below output at runtime.

const currentDirection = "UP" /* Up */;

This approach holds good if the enum and its usage are in the same module. However, when the enum declaration and currentDirection are in separate files, one has to import the enum into the module that has currentDirection. Now, the transpiler has to read both the modules to substitute the value for Direction.Up.

However, the transpilers including Babel operate on one file at a time, that is

  • The compiler reads a module
  • The module’s type information is stripped
  • The remaining is written as the JavaScript file.

This process does not read imported modules. Hence, the value replacement for currentDirection with UP is not possible with this approach. Typescript's transpileModule function and babel's babel-plugin-transform-typescript follows this approach to compile Typescript files.

Typescript has isolatedModules compiler option when enabled performs additional checks to ensure the compiled code is safe for this compilation process.

Const assertions

There is an alternative in Typescript called const assertions that solves some of the problems described above.

Let’s understand two important concepts in Typescript which are widening and non-widening literal types before learning about const assertions.

Widening literal types

The following declaration, when hovered in your IDE, shows a tooltip with type information as let fileName: string. This means the fileName type is widened to the type stringsince the variable initialized with let can be changed anytime. Typescript is intelligent enough to infer the type as string when declared with let.

let fileName = "const-assertions.md";

That is,

let fileName = "const-assertions.md";
fileName = "typescript.md"; // No type error
fileName = 15;
// Type error - Type 'number' is not assignable to type 'string'

This applies for strings, number, boolean as well.

let isAuthenticated = false; // Type - booleanlet itemsInCart = 12; // Type - number

However, if you have

const userName = "Aishwarya"

The tooltip would show the type for userName as Aishwarya instead of string. You can view this in Typescript playground here to get a better understanding.

This is because the const variable can be assigned only once and hence the value Aishwarya can never be changed again in any part of the code. So, Typescript inferred the type as Aishwarya.

Non-widening literal types

In the below code, the type of newCurrentDay is string whereas the type of currentDay is Sunday.

const currentDay = "Sunday"; // Type : "Sunday"let newCurrentDay = currentDay; // Type: string

Now, if we use a const assertion as const to the const variable and assign it to the let variable newCurrentDay, then it cannot be changed and produces a type error as given below.

const currentDay = "Sunday" as const;let newCurrentDay = currentDay; // Type: SundaynewCurrentDay = "Monday"; // Error: Type '"Monday"' is not assignable to type '"Sunday"'.

These are called non-widening types. Here, the type of newCurrentDay is not widened to a generic type string, instead, it is assigned to Sunday.

Let’s consider another example with arrays:

const HEADING_LEVEL_1 = "h1" as const;const HEADING_LEVEL_2 = "h2" as const;const allowedLevels = [HEADING_LEVEL_1, HEADING_LEVEL_2]; 
// Type: ("h1" | "h2")[]
allowedLevels.push("h3")
// Error: Argument of type '"h3"' is not assignable to parameter of type '"h1" | "h2"'

If you remove as const at the end of HEADING_LEVEL_1 and HEADING_LEVEL_2, then the type would be string[]. In the case of arrays, instead of widened type string, we have two non-widened types h1 and h2.

With the understanding of the above two types in Typescript, let’s try to use const assertions in place of enums.

Consider this const object:

const Direction = {   Up: "UP",   Down: "DOWN"}

If you inspect the type of the above variable Direction, it would be

const Direction = {   Up: string;   Down: string;}

Here, the type of Up and Down is string. That is because we could change the value of these keys at any point in the code like below.

Direction.Up = "New Up"Direction.Down = "New Down"

Now, let’s try to execute the following code

The processDirection throws an error as mentioned in the comment next to it. To solve this, we could do the following.

const Direction = {   Up: "UP" as "UP",   Down: "DOWN" as "DOWN"}

Now if you inspect the Direction's type, it would be

// Typeconst Direction = {  Up: "UP",  Down: "DOWN"}

Now, calling the processDirection function with Direction.Up produces no type error.

But with this approach, there’s too much noise where we have to use as on every key. It can be further simplified as follows:

const Direction = {   Up: "UP",   Down: "DOWN"} as const

And now if you inspect the type of above code

const Direction: {  readonly Up: "UP";  readonly Down: "DOWN";}

This means object literals get readonly properties whereas array literals get readonly tuples.

Now, let’s make processDirection function to utilize the new value. Since this is a value, you need to extract the type from the const as follows:

type DirectionType = typeof Direction[keyof typeof Direction]

Now, if you inspect the above code, it would give

type DirectionType = "UP" | "DOWN"

Let’s dissect the code to understand it better. The above code operates much like accessing the values of an object.

That is, if you want to extract the values of an object without using Object.values, you would do something like

Object.keys(Direction).map((key) => Direction[key]) 
// Outputs ["UP" | "DOWN" ]

The type DirectionType does the same. Here typeof Direction is much like the actual object, and I extract all the keys using keyof keyword and I get all the values by indexing DirectionType[keyof DirectionType] => Direction[<!-- keys of Direction ->]

This can be further simplified as follows:

type Values<ObjectLiteral> = ObjectLiteral[keyof ObjectLiteral];const Direction = {   Up: "UP",   Down: "DOWN"} as const;type DirectionType = Values<typeof Direction>;function processDirection(direction: DirectionType) {    /**  Code */   return callbacksMap[direction];}

I created a generic type Values that takes an object literal type ObjectLiteral as an argument. This type extracts the values of the keys from ObjectLiteral — `typeof Direction`: which are “UP” and “DOWN” in our case.

An application would have many const assertions like this and we don’t want to repeat this line for every assertion (Following DRY) and now DirectionType is simple with just typeof Direction.

Another advantage with const assertions is that they are type-safe with numbers as well.

Let’s try to rewrite the ShirtSize example we saw above with const assertion.

In this example, passing 20 to size produces error correctly as expected and is type-safe. These const assertions do not have the auto initializing feature that numeric enum offers.

With this approach, we don’t have any additional runtime code saving bytes of code. It provides the necessary autocomplete feature and is also type-safe for all kinds of values.

But, we need to include an extra line of code for the type, ie: type DirectionType = Values<typeof Direction>. In my opinion, adding this one line of code is not a problem considering the benefits of file size reduction and the main thing: type safety with numbers.

Union types

Yet another possible solution to handle these kinds of scenarios is union types.

type Direction = "UP" | "DOWN";function processDirection(direction: Direction) {   /**  Code */   return callbacksMap[direction];}processDirection("UP");// For numberstype ShirtSize = 28 | 30 | 32 | 34;

This approach is type-safe, has no values generated at runtime, no boilerplate code and is straightforward. But one problem with this approach is that the magic strings and numbers will be distributed all over the code.

Conclusion

In the end, what suits best depends entirely on the scenario. As we learnt that not every type we discussed is perfect, each one has a pitfall. But you have to make the right trade-off depending on your scenario.

I would completely avoid the usage of numeric enums because they are not type-safe. The purpose of bringing the additional complexity of using Typescript is the confidence that it provides with its type safety. If the main objective is invalidated, then there is no point in using it. If you are concerned about the file size and code generated at runtime in frontend projects because of string enums, you can consider const assertions or union types. But if it is a node project, you are safe to use string enums as the file size is not a big deal and, you can save some boilerplate with const assertions or the magic strings from union types.

But the most important thing of all is consistency. If you decide to follow an approach, stick to it and be consistent all over the project.

One final note before I sign off is, when Typescript was introduced in 2012, they introduced many runtime features that were not present in Javascript. But over time, TC39 which governs Javascript added many of the same features to Javascript. And the features they added were not consistent with what was present in Typescript already. This left the Typescript team in an unpleasant situation. So, they articulated their design goal which states: TC39 defines the runtime whereas Typescript improves its quality in the “Type system”.

So, when you choose to use a feature in Typescript that does not exist in Javascript, there is a possibility that TC39 may introduce it with a different syntax. Many features that are present in Javascript right now including classes, optional chaining, nullish coalescing were all adopted from Typescript. A nice blog about that.

--

--