Deep dive into enums, const assertions and union types
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 assertions
etc. 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 string
since 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.