Alexander Nnakwue Software engineer. React, Node.js, Python, and other developer tools and libraries.

TypeScript string enums, and when and how to use them

10 min read 2872

TypeScript string-enums-guide

An intro to string-based enums

In this section, we are going to explore string enums in TypeScript.

If you are new to the JavaScript/TypeScript landscape, you might be wondering what Enums are. The enums keyword offers a way for us to define a finite set of values — usually as named constants in a strongly typed way. They also allow us to specify a list of values for a particular collection set or type.

enums are not natively supported in JavaScript, but there is a workaround that lets us replicate their behavior in the language using the Object.freeze construct, since TypeScript treats enums as if they were real objects (although applicable for non-const enums at runtime). We can use this construct as shown in the example below:

const directionEnum = Object.freeze({ 
    UP :  "UP", 
    DOWN:  "DOWN" 
});  

console.log(directionEnum) //{ UP: 'UP', DOWN: 'DOWN' } 

Doing this, we get an object that is read-only and protected. Note the read-only definition is only shown when we hover the mouse over the directionEnum const:

const directionEnum: Readonly<{ 
    UP: string; 
    DOWN: string; 
}> 

The Object.freeze method prevents the modification of existing property attributes and values, as well as prevents the addition of new properties. This sort of mirrors the idea behind enums, since they are meant to have a definite number of fixed values for a given variable declaration.

Enums are not a new concept in programming. As we may already know, most programming languages like Java, C, and so on, have the concept of enums defined in their core. We can also picture enums as artificially created types that contain a finite set of values, just like we have the boolean type, which contains only false and true values.

As a result of their usefulness and advantages in other languages, enums were introduced and added to the TypeScript landscape. However, enums are quite unique and specific to TS, in that they are not a typed extension of, equivalent to, or correspond with a specific feature in the JavaScript language, despite TS being a typed superset of JS.

Note: There is a current ECMAScript stage-0 proposal to add enums to JavaScript. More details in this repo.

An intro to number-based enums

Enums are number-based by default. For number-based enums, members are distinct from each other due to their auto-incrementing nature. They can either have an initializer (whereby we explicitly specify the enum member values), or not.

Let us see an example below.

We made a custom demo for .
No really. Click here to check it out.

enum statusEnumWithInitializer = { 
"OPEN" = 10, 
"CLOSE", 
} 
//statusEnumWithInitializer.CLOSE = 11  
//Since the following members are auto incremented from that point on.  

If we leave off the initializers, we have this below:

enum statusEnumWithoutInitializer = { 
"OPEN", 
"CLOSE", 
} 
//statusEnumWithoutInitializer.OPEN = 0 
//statusEnumWithoutInitializer.CLOSE = 1 

As we can see from the above examples, accessing the members of an enum is as simple as accessing the properties of objects. We mentioned earlier that the reason for this is that non-const enums have object-like behavior.

Notice the trailing commas in the above code snippet. Using these enum types in real-world cases is quite straightforward because we can simply declare them as types and pass them as arguments to functions.

Due to the auto-incrementing behavior of number-based enums, there exist a caveat for these enums when there are no initializers. They either need to be declared first or must succeed numeric enums initialized with number-based constants, also applicable in heterogenous enums. This is because enums need to be fully evaluated at compile time.

When do we need string-based enums?

Usually, enum types come in handy when we intend to declare certain types that must satisfy certain criteria defined in the enum declarations. As we mentioned earlier, while enums are numerically based by default, TypeScript ≥ version 2.4 supports string-based enums.

String-based enums, just like object literals, support computed names with the use of the square bracket notation, and this is usually not the case for number-based enums. Therefore, this limits our ability to use number-based enums in JSON objects, since it is usually not possible to compute the names of these kinds of enum members.

String enums are serializable over transfer protocols and are easily debuggable — since they are just strings, after all. They also allow for a meaningful and readable value at runtime, independent of the name of the enum member.

For completely string-based enums, we cannot omit any initializers, as all enum members must come with values. But this is not the case for numeric enums, which end up as only plain numbers and therefore might not too useful.

Throughout this article, we will focus on string-based enums. Please refer to the TypeScript documentation for more information on number-based enums. If you are in need of a general introduction to enum types or an overview of ways enums could be misused in the language, the LogRocket blog has you covered.

Constant and computed enums

Constant-based enums are enums that have a single member without an initializer value. This means that they are automatically assigned the value of 0.

They can also have more than one member value, whereby the first member must be a numeric constant. This means that subsequent values are incremented by adding one to the preceding numeric constants, in that order.

In summary, for constant enums, the enum member value can be the first member without an initializer or must have an initializer which is numeric, if it has other enum members preceding it.

Note that constant enum values can be computed or calculated at compile time. For the case of computed enums, they can be initialized via expressions. See an example of a computed enum below:

enum computedEnum { 
 a = 10 
 str = "str".length // computed enum 
 add = 300 + 100 //computer enum 
} 

Specifying enum member values

We can specify enum member values in the following ways, as needed:

  • Literal enum members: Literal enum types are enum types where each member either has no initializer, or an initializer that specifies a numeric literal, a string literal, or an identifier naming another member in the enum type. Values are initialized implicitly via number literals, or explicitly via string literals
  • Constant enum members: Const-based enum members can only be accessed using a string literal. They are initialized via expressions, which can either be specified implicitly or explicitly using strings literal, number literals, parentheses and so on
  • Computed enum members: We can initialize these via arbitrary expressions

String literals and union types

In general, literal types are JavaScript primitive values. As of TypeScript ≥ version 1.8, we can create string literal types. Specifically, string literal types allows us to define a type that accepts only one specific string literal. On their own, they are usually not so useful, but when they are combined with union types, they become immensely powerful.

They mimic a string enum’s expected behavior when used in conjunction with union types, since they also provide a reliable and safe experience for named string values. See the example below:

type TimeDurations = 'hour' | 'day' | 'week' | 'month'; 

var time: TimeDurations; 
time = "hour"; // valid  
time = "day";  //  valid 
time = "dgdf"; // errors 

From the above, the TimeDurations type looks like a string enum, in that it defines several string constants.

Enum types can effectively become a union type of each enum member. A combination of string literals and union types offers as much safety as enums, and has the advantage of translating more directly to JavaScript. It also offers similarly strong autocomplete in various IDEs. If you’re curious, you can quickly check this on the TypeScript Playground.

For a string enum, we can produce a union type out of the literal types of its values, but it doesn’t happen the other way.

Use cases and importance of string-based enums

String-based enums were only introduced to TypeScript in version 2.4, and they made it possible to assign string values to an enum member. Let us look at an example from the documentation below:

 enum Direction { 
  Up = "UP", 
  Down = "DOWN", 
  Left = "LEFT", 
  Right = "RIGHT", 
} 

Before now, developers had to rely on using string literals and union types to describe a finite set of string values, just as string-based enums do. So, for example, we could have a type defined like so:

type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT";  

We can then make use of the type as, say, a function parameter, and the language can check that these exact types are passed at compile time to the function when instantiated.

In summary, to make use of string-based enum types, we can reference them by using the name of the enum and their corresponding value, just as you would access the properties of an object.

At runtime, string-based enums behave just like objects and can easily be passed to functions like regular objects. See the TypeScript documentation for an example of how enums behave as objects at runtime.

Let us remember that for string-based enums, the initializers cannot be omitted, unlike for number-based enums, where the initializer provided is either the first member or the preceding member and must have a numeric value. Each enum member must be initialized with a constant that is either a string literal or another enum member that is a string and part of a string enum.

String enums are heavily used in JSON objects for validating API calls to do things like ensure parameters are passed correctly. Another wonderful use case is in their application in defining domain-specific values for predefined APIs.

The importance of enum types can’t be overstated. For instance, whenever we make use of an enum member in our code for validation purposes, TypeScript checks statically that no other values are used.

They also come in handy for ensuring safe string constants. Enums offer a more self-descriptive option than making use of boolean values. Instead, we can specify enums that are unique to that domain, which makes our code more descriptive.

Therefore, we can decide to add more options later, if we need to, as compared to when we use boolean checks. Let us explore an example below where we can check if an operation succeeded or failed via a boolean check and with the use of an enum type declaration:

class Result { 
  success: boolean; // in our code we can set this to either true or false 
  // also we must have seen constructs like `isSuccess` = true or false 
} 

//compared to using enums which are more descriptive of our intentions 
enum ResultStatus { FAILURE, SUCCESS } 

class enumResult { 
  status: ResultStatus; 
} 

To create a type whose values are the keys of enum members, we can make use of keyof with the typeof method.

enum Direction { 
  Up = "UP", 
  Down = "DOWN", 
  Left = "LEFT", 
  Right = "RIGHT", 
} 

 // this is same as 
 //type direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'; 
type direction = keyof typeof Direction; 

As the TypeScript documentation says, even though enums are real objects that exist at runtime, the keyof keyword works differently than you might expect for typical objects.

Instead, using keyof typeof will get you a type that represents all enum keys as strings, as we have seen above.

Comparing number- and string-based enums

A subtle difference between number-based enums and string-based enums is that for number-based enums, there is reverse mapping for number-valued members.

Reverse mapping allows us to check if a given value is valid in the context of the enum. To understand this better, let us look at the compiled output of a number-based enum:

//As we can see for number-based Enums, we could decide to leave off the initializers and the members autoincrements from Unauthorized -401 and so on -
enum StatusCodes { 
  OK = 200, 
  BadRequest = 400, 
  Unauthorized, 
  Forbidden, 
  NotFound, 
} 

//the transpiled JavaScript file is shown below (ignore the wrong status code ;)) 
var StatusCodes; 
(function (StatusCodes) { 
    StatusCodes[StatusCodes["OK"] = 200] = "OK"; 
    StatusCodes[StatusCodes["BadRequest"] = 400] = "BadRequest"; 
    StatusCodes[StatusCodes["Unauthorized"] = 401] = "Unauthorized"; 
    StatusCodes[StatusCodes["Forbidden"] = 402] = "Forbidden"; 
    StatusCodes[StatusCodes["NotFound"] = 403] = "NotFound"; 
})(StatusCodes || (StatusCodes = {})); 

If we look at lines 13-17 above, we will see that we can resolve a value by its key, and a key by its value. On line 13, StatusCodes["OK"] = 200 is also equal to StatusCodes[200] = "OK".

Unfortunately, this does not apply for string-based enums. Instead, in string-based enums, we can assign string values directly to enum members. Let us see the transpiled output below:

enum Direction { 
  Up = "UP", 
  Down = "DOWN", 
  Left = "LEFT", 
  Right = "RIGHT", 
}  

//the transpiled .js file is shown below - 
var Direction; 
(function (Direction) { 
    Direction["Up"] = "UP"; 
    Direction["Down"] = "DOWN"; 
    Direction["Left"] = "LEFT"; 
    Direction["Right"] = "RIGHT"; 
})(Direction || (Direction = {})); 

enum Direction { 
  Up, 
  Down, 
  Left, 
  Right, 
} 

//this will show direction '1' 
console.log(Direction.Down);  

// this will show message 'Down' as string representation of enum member 
console.log(Direction[Direction.Down]); 

String enums can be easily inferred from their indices or by looking at the initializer of their first member. The auto-initialized value on an enum is its property name. To get the number value of an enum member from a string value, you can use this:

const directionStr = "Down"; 
// this will show direction '1' 
console.log(Direction[directionStr]); 

We can use these values to make simple string comparisons, e.g.:

// Sample code to check if a user presses "UP" on say a game console 
const stringEntered = "UP" 

if (stringEntered === Direction.Up){ 
    console.log('The user has pressed key UP'); 
    console.log(stringEntered); //"UP" 
}

As we mentioned earlier, logging members of numeric enums is not so useful because we are seeing only numbers. There is also the issue of loose typing when using these enums types, as the values that are allowed statically are not only those of the enum members — any number is accepted. Weird, right? See an illustration below:

const enum LogLevel { 
  ERROR, 
  WARN, 
  INFO, 
  DEBUG, 
} 

function logger(log: LogLevel) { 
  return 'different error types'  
} 
console.log(logger(LogLevel.ERROR)) // "different error types"  
console.log(logger(12)) // "different error types"  

Enums at runtime and compile time

Const-based enums

With const-based enums, we can avoid the generated code by the TS compiler, which is useful when accessing enum values. Const-based enums do not have a representation at runtime. Instead, the values of its members are used directly.

Const-based enums are defined as regular enums except with the use of the const keyword. The only difference is in their behavior and usage. Let us see an example below:

const enum test  { 
  A, 
  B, 
} 

//usage  
function testConst(val: test) { 
   if(test.A) { 
     return "A" 
   }  
   if(test.B) { 
     return "B" 
   } else { 
     return "Undefined" 
   } 
} 

Const-based enums can only use constant enum expressions and are not added during compilation, which is unlike regular enum declarations. Const enum members are also inlined at use sites. This, therefore, infers that const enums cannot have computed members. After compilation, they are represented as shown below:

console.log(testConst) 

function testConst(val) { 
    if (0 /* A */) { 
        return "A"; 
    } 
    if (1 /* B */) { 
        return "B"; 
    } 
}  

As we can see from the above, the enum member values are inlined by TS. Also, const enums do not allow for a reverse lookup, which behaves like regular JavaScript objects.

Enum members can also become types as well. This is applicable when certain enum members only have their corresponding values. We should also note that, to emit the mapping code regardless of const enums, we can turn on the preserveConstEnums compiler option in our tsconfig.json file.

If we compile our code again with the preserveConstEnums option set in the TS config file, the compiler will still inline the code for us, but it will also emit the mapping code, which becomes useful when a piece of JavaScript code needs access to it.

Best practices for legacy support

In modern TypeScript, there is an approach of implementing string-based enums so that they are backwards compatible in the JavaScript landscape. This is done in the hope that we can make use of this syntax when the ECMAScript finally implements enums in JavaScript. This is where legacy support comes into the picture.

To do this in TypeScript, we can make use of the as const method with a regular object. Let us see an example below:

const Direction = { 
  Up: 0, 
  Down: 1, 
  Left: 2, 
  Right: 3, 
} as const; 

However, to use this approach to get the keys in our code, we need to use the keyof typeof method. See an example below.

type Dir = typeof Direction[keyof typeof Direction]; 

Conclusion

String enums are flexible in that we can either add key-values as enums, or just keys, if the key-values are the same and if we do not care about the case sensitivity of our enums. When using string enums in TypeScript, we do not necessarily need to know the exact strings each enum value contains.

It is also important to point out that while enums can be mixed with string and numeric members (heterogeneous), it is not clear why you would ever want to do this.

Enum member values can either be constant or computed, as we have earlier discussed. For constant enums, the enum must be the first member and it has no initializer. Also, their values can be computed or calculated at compile time. The TypeScript documentation contains more details on constant versus computed enums, if you’re interested.

The wonderful thing about working with a typed language like TypeScript and using a feature like this is that popular IDEs like Visual Studio Code can help us choose enum values from a list of values via autocomplete. This is especially useful when we are tasked with making comparisons between or among values from an enum, or even in their regular usage.

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Alexander Nnakwue Software engineer. React, Node.js, Python, and other developer tools and libraries.

Leave a Reply