Enumerations or enums allow developers to define a set of named constant values. They are useful for representing a fixed set of options or states. For example, an enum can define the possible statuses for an order as "Pending", "Processing", "Shipped" and "Delivered".
Enums promote code clarity by using meaningful labels rather than bare numbers or strings. They also ensure type safety as the compiler will limit values to only the defined enum options.
While JavaScript does not have built-in enum support, there are various ways enums can be simulated. TypeScript provides native enum capabilities with more flexibility and type safety compared to plain JavaScript.
In this article, we'll compare enums in JS and TS and see how they are implemented differently.
Note: all generated JavaScript code is generated from the TypeScript v4.1 compiler.
"Enumerations" in JavaScript
Since enumerations are not part of the JavaScript language specification, they have to be simulated using other data types:
Object Constants
One approach is to define an object with property names representing the enum values:
const OrderStatus = {
PENDING: 'pending',
PROCESSING: 'processing',
SHIPPED: 'shipped',
DELIVERED: 'delivered'
};
This provides meaningful names for each constant value. However, it lacks efficiency since objects come with more overhead vs primitive values.
Integer Constants
A simpler approach is to use numeric constants as the enum values:
const OrderStatus = {
PENDING: 0,
PROCESSING: 1,
SHIPPED: 2,
DELIVERED: 3
};
While efficient, integer enums lack explicit meaning without constant names.
String Constants
Another option is to use string values for the enum:
const OrderStatus = {
PENDING: 'pending',
PROCESSING: 'processing',
SHIPPED: 'shipped',
DELIVERED: 'delivered'
};
String enums are self-documenting with value meanings. However, they allow any arbitrary string value rather than limiting it to just the defined constants.
Overall, object constants provide the best capabilities for simulated Enums in JavaScript. But they come with tradeoffs vs the simplicity of numeric or string constants.
Enumerations in TypeScript
TypeScript provides built-in enum support with the enum keyword. Enums in TypeScript have two types - numeric and string enums.
Numeric Enums
Numeric enums have auto-incrementing numbers as values by default:
enum OrderStatus {
PENDING, // 0
PROCESSING, // 1
SHIPPED, // 2
DELIVERED // 3
};
We can also explicitly specify number values:
enum OrderStatus {
PENDING = 5,
PROCESSING = 6,
SHIPPED = 7,
DELIVERED = 8
};
String Enums
String enumerated types allow string values instead:
enum OrderStatus {
PENDING = 'pending',
PROCESSING = 'processing',
SHIPPED = 'shipped',
DELIVERED = 'delivered'
};
Const Enums
Numeric enums can be marked as const to generate optimized JavaScript code:
const enum OrderStatus {
PENDING,
PROCESSING,
SHIPPED,
DELIVERED
};
Const enums values are inlined at compile time rather than runtime. The enum definition itself generates no JavaScript code.
For example:
const enum OrderStatus {
PENDING,
PROCESSING
};
let status = OrderStatus.PENDING;
Compiles to:
let status = 0;
This is known as inlining and is in contrast to what is generated in the non-const enum scenario which would look more like the following:
var OrderStatus;
(function(OrderStatus) {
OrderStatus[OrderStatus["PENDING"] = 0] = "PENDING";
})(OrderStatus || (OrderStatus = {}));
let status = OrderStatus.PENDING;
The const enum
code avoids the runtime lookup and makes enum usage more efficient.
The key benefits of TypeScript enums are:
- Type safety - only allow valid enum values
- Meaningful constant names
- Flexibility with numeric and string-based enums
- Performant const enums optimized by the compiler
However, in the next section we will see we are not always able to exploit inlining, so pay attention.
When can the TypeScript typechecker not inline via const enum?
If you need to compute any enum value then you may not be able to
take advantage of the lower bloat benefits of using const enum
in
more places.
For example, suppose we have a configuration file generated by our
deploy process that is imported, and a value in there is queried to
generate a derived value for the value of any enum value, then we
would not be able to define it as a const enum
.
Let's say this is the generated config:
{
"currentInstanceSize": "t3.medium"
}
We try to use the config.currentInstanceSize
as the string value
for one of our enum values like so:
import config from './config';
const enum InstanceSize {
t3_small = 't3.small',
t3_medium = 't3.medium',
t3_large = 't3.large',
current = config.currentInstanceSize,
}
Then the TypeScript typechecker would say something like the following in our error output:
Computed values are not permitted in an enum with string-valued members.
Another limitation of const enum
definition is that you cannot do a
dynamic lookup of the value from a string, such as:
const lookup = 'm3_small';
const instanceSize = InstanceSize[lookup];
This yields the typechecker error of:
A const enum member can only be accessed using a string literal.
Yay for type errors letting us know when we can't do something instead of waiting until runtime for something to blow up in unpredictable ways.
Ambient Enums in TypeScript
TypeScript provides support for ambient enums using the declare
keyword. These are used to describe the shape of an existing enum type.
For example, let's say we are consuming a third-party library that exposes an enum type called LibraryStatus
:
declare enum LibraryStatus {
Ready,
Loading,
Failed
}
This declares the shape of the enum without generating any JavaScript code.
They are useful in situations where:
- We want to get TypeScript benefits for enums defined in plain JavaScript code
- Working with external library typings that include enums
- Consuming enums exposed from another module
To understand ambient enums more clearly, let's go through an example:
We have a JavaScript library called data-lib
that exposes an enum:
// data-lib.js
export const Status = {
READY: 'ready',
LOADING: 'loading',
FAILED: 'failed'
}
In our TypeScript code, we want autocomplete and type safety when using this Status enum.
So we first import the JavaScript library:
import { Status } from 'data-lib';
Then we declare an ambient enum with the same shape:
declare enum Status {
READY = 'ready',
LOADING = 'loading',
FAILED = 'failed'
};
Now when we use the imported Status enum, TypeScript understands its structure:
const getData = () => {
if (Status.LOADING) {
// ...
}
};
The ambient enum provides safety without generating any JS code. It simply augments TypeScript's understanding of the JavaScript Status enum that already exists in data-lib
.
In the above scenario, ambient enums allowed us to describe the shape of an enum defined in JavaScript code that we need to interface with but allowed us to unlock autocomplete, type-checking, and other benefits of TypeScript enums in our TypeScript application.
Ambient enums can also be used to hide or subset the values of a third-party TypeScript enum so that only approved values are exposed to internal code.
Let's say we are using a third-party library external-lib with an enum:
// external-lib
export enum ExternalStatus {
READY,
PENDING,
FAILED,
BLOCKED // internal status
}
In our application's code, we only want to expose READY
, PENDING
, and FAILED
to our internal users.
We can create an ambient enum to subset the external values:
// our application code
import { ExternalStatus } from 'external-lib';
declare enum AppStatus {
READY = ExternalStatus.READY,
PENDING = ExternalStatus.PENDING,
FAILED = ExternalStatus.FAILED,
}
function getStatus() {
return AppStatus.READY; // only approved statuses
}
Now the AppStatus
ambient enumeration only exposes the approved values from ExternalStatus
. Our internal code only has access to our AppStatus
, so we can't mistakenly use the BLOCKED
value.
This technique allows us to wrap a third-party enum with our own approved values. The ambient enum acts as an abstraction layer and type safety guardrail for internal code.
In both cases described above for the usage of ambient enums there are some tradeoffs and things to keep in mind when using them:
- Version maintenance
- Any time the external library's enum definition changes, we'll need to update our ambient enum accordingly. This dependency needs to be managed on each upgrade.
- No runtime checking
- The ambient enum only provides compile-time safety. At runtime, the underlying JavaScript enum values are still accessible. So there's no enforcement against using non-approved values.
- Limits intellisense
- Editor auto-complete will be based on the ambient enum values, not the full external enum. This reduces intellisense fidelity.
- Duplicate declarations
- Having both the external enum and ambient enum duplicates some declarations. This can add overhead and complexity.
- No additional semantics
- Beyond limiting visibility, the ambient enum doesn't allow adding significant new semantics or behaviors.
Given these tradeoffs, you might want to consider some other options for limiting the enum values used from an external dependency instead of ambient enums:
- Using the external enum directly and disciplined internal usage
- Wrapping the external API to mediate internal usage
- Redefining selected values as static
readonly
strings
So ambient enums provide some useful capabilities, but also come with maintenance and runtime limitations to be aware of. Worth weighing if it's the best choice vs. other possible approaches for a given situation.
Some other differences and limitations are enumerated (I couldn't help myself) in the TypeScript Handbook: Enums.
Frequently Asked Questions
How are enums simulated in JavaScript?
JavaScript can simulate enums via object constants, integer constants, or string constants. Each approach has tradeoffs.What are the different types of TypeScript enums?
TypeScript has numeric enums, string enums, and const enums. Const enums get compiled to inlined constant values.When can const enums not be inlined?
If enum values are computed dynamically or referenced by a variable, the compiler cannot inline const enum values.What are ambient enums in TypeScript?
Ambient enums declare the shape of an existing enum without generating code. This provides type safety for external enums.When would you use an ambient enum?
Use ambient enums when: consuming a JavaScript enum, working with external typings, or restricting the visibility of a third-party enum.What are the limitations of ambient enums?
Ambient enums require version maintenance, provide only compile-time safety, limit intellisense, and duplicate declarations.If you enjoyed this content, please consider sharing this link with a friend, following my GitHub or LinkedIn accounts, or subscribing to my RSS feed.