Editor’s note: This article was last updated by Ibiyemi Adewakun on 15 August 2024 to cover using TypeScript’s const assertion, as const
, to extend enums as an alternative to using the union type.
TypeScript is well-loved by the developer community for many reasons, one of which being the static checks it provides to code written in it. Spotting problems early in your development lifecycle can save days of debugging random, vague errors that can sometimes pop up when using dynamic languages like JavaScript.
TypeScript can help make your code more predictable and better documented, make refactoring easier, and help reduce potential errors you might face at runtime in your production app.
One language mechanism that is pivotal to TypeScript is enums. In this article, we’ll discuss what enums are, why you would need to extend them and how to do so, and best practices for working with TypeScript enums.
Enums aren’t a feature of typing, interestingly, like most of TypeScript is — in fact, they are one of the few, new features that enhance the language.
Enums allow developers to define a strict set of options for a variable. Here’s an example:
enum Door { Open, Closed, Ajar // half open, half closed }
Enums default to number enums, so the above enum is essentially an object with 0
, 1
, and 2
as its key, which we can see in the following transpiled JavaScript code:
"use strict"; var Door; (function (Door) { Door[Door["Open"] = 0] = "Open"; Door[Door["Closed"] = 1] = "Closed"; Door[Door["Ajar"] = 2] = "Ajar"; // half open, half closed })(Door || (Door = {})); console.log(Door.FullyOpened);
In TypeScript, you can also use string enums, like so:
enum Door { Open = "open", Closed = "closed", Ajar = "ajar" // half open, half closed }
If you then used this Door
enum, you could ensure that variables only used the three options specified in the enum. So, you couldn’t assign something incorrectly by accident or easily create bugs this way.
If you do try to use another variable, it will throw a type error like this:
enum Door { Open = "open", Closed = "closed", Ajar = "ajar" // half open, half closed } console.log(Door.FulyOpened)
Property 'FullyOpened' does not exist on type 'typeof Door'.
Extension is one of the four pillars of object orientation and is a language feature present in TypeScript. Extending an enum allows you to essentially copy a variable definition and add something extra to it.
For example, you might be trying to do something like this:
enum Door { Open = "open", Closed = "closed", Ajar = "ajar" // half open, half closed } enum DoorFrame extends Door { // This will not work! Missing = "noDoor" } console.log(DoorFrame.Missing)
We could then add extra properties into an enum, or even merge two enums, to still get strict typing on our enum while also being able to change them after they’ve been defined.
But, notice how the above code snippet doesn’t work! It fails to transpile and throws four different errors.
The short answer is no, you can’t extend enums because TypeScript offers no language feature to extend them. However, there are workarounds you can use to achieve what inheritance would.
enum Door { Open = "open", Closed = "closed", Ajar = "ajar" // half open, half closed } enum DoorFrame { Missing = "noDoor" } type DoorState = Door | DoorFrame; let door: DoorState; door = Door.Ajar console.log(door) // 'ajar' door = DoorFrame.Missing console.log(door) // 'noDoor'
In the above code block, we used a union type. The union acts like an “or,” which simply means that the DoorState
type will either be of type Door
or type DoorFrame
.
This now means DoorState
can use either of the variables from the two enums interchangeably.
However, an important caveat is that we lose one of the biggest benefits of an enum, which is referencing the enum’s options like a normal object property e.g., DoorState.Open
or DoorState.Missing
.
In TypeScript, it also isn’t possible to use the values of the enums, like ajar
and noDoor
. Our only option is to reference the individual enums we unioned like DoorFrame.Missing
or Door.Open
, which is a limitation.
When our TypeScript enum is transpiled, it becomes a JavaScript object with the keys and values that our enum specifies.
In TypeScript, we could write purely JavaScript if we wanted to. In fact, this is a big strength of TypeScript. You could, for example, rename all your file.js
to file.ts
and turn off the compiler checks for your code. As long as you run the compile/transpile steps, everything will work fine, with zero code changes.
So with the understanding that when our enum becomes an object literal when transpiled, we can treat it like a JavaScript object and use the spread syntax like below to create a new object with more properties (options):
enum Move { LEFT = 'Left', RIGHT = 'Right', FORWARD = 'Forward', BACKWARD = 'Backward' } const myMove = { ...Move, JUMP: 'Jump' }
This solution has been described secondly, though, because it isn’t as good of a solution as the union type. This is because the “composition” of your enum is occurring at runtime, whereas when we use the union type, type checking can occur at compile/transpile time, not runtime.
as const
Above, we explored using TypeScript’s union type as a way to extend our enums and the limitations of that approach.
Another option is to use TypeScript’s const assertion, as const
. To properly understand this option, there are two key things to keep in mind:
Using these properties of const assertions, we can create and combine read-only object literals to our enum options and create a type with this newly formed object. Here’s an example:
const Door = { Open: "open", Closed: "closed", Ajar: "ajar" // half open, half closed } as const const DoorFrame = { Missing: "noDoor" } as const const DoorState = { ...Door, ...DoorFrame } as const type DoorState = typeof DoorState[keyof typeof DoorState] let door: DoorState; door = DoorState.Open console.log("Door has a value matching enum object Door", door) door = DoorState.Missing console.log("Door has a value matching enum object DoorFrame", door)
Our above option gives us the ability to get the benefits of an enum using JavaScript object literals.
We can also use the power of TypeScript generics to create a type DoorState
that will only allow assignment from the object literal DoorState
‘s properties:
door = "apple" // throws Error - Type '"apple"' is not assignable to type 'DoorState'. door = DoorState["apple"] // sets undefined but throws no errors
If you’re interested, you can learn more about TypeScript’s const assertions here.
We have discussed how we can extend enums in Typescript, but enums aren’t a magic trick to be used to fix all problems. When used incorrectly, enums can make your code readability, scalability, and maintainability worse, rather than improve your code.
So, let’s cover some best practices and common patterns to use when working with enums in TypeScript.
I have explained how we can have string enums like this:
enum Seasons { Summer = "Summer", Winter = "Winter", Spring = "Spring", Fall = "Fall" }
alongside numerical enums like this:
enum Decision { Yes, No }
But there is a third type of enum that you may not be aware of, called a heterogenous enum. This is where you can use a string and numerical enums in the same enum. Here is an example from the docs:
enum BooleanLikeHeterogeneousEnum { No = 0, Yes = "YES", }
It’s important to note that while this is a possibility, TypeScript’s documentation discourages this practice. Rather than creating a heterogenous enum such as our above example, you are encouraged to:
Sometimes, code functionality can be forced to adhere to an enum option, which can quickly turn into an anti-pattern.
Here’s an example from the following article:
enum Operators { Add, Subtract } function calculate(op: Operators, firstNumber: number, secondNumber: number) { switch(op) { case Operators.Add: return firstNumber + secondNumber case Operators.Subtract: return firstNumber - secondNumber } }
The above code looks fairly simple and safe because our example is, indeed, simple and safe. But in large codebases, when you strictly tie implementation details to enum types like this, you can cause a few issues:
If you need to do something like the above, a simpler (and more condensed) pattern could look like this:
const Operators = { Add: { id: 0, apply(firstNumber: number, secondNumber: number) { return firstNumber + secondNumber } }, Subtract: { id: 1, apply(firstNumber: number, secondNumber: number) { return firstNumber - secondNumber } } }
You can read more about this pattern here.
There is a way of generally grouping together different types of data utilized in code: discrete variables or continuous variables.
Discrete variables are data that have clear spaces between their representations and have only a few representations. Examples include:
Discrete data is a good candidate to be placed inside an enum, and it can help code clarity and reuse. Continuous data refers to data without clear gaps or options; they often have values that can be a continuous sequence, such as numbers. A good example of continuous data is measurements, such as a person’s weight or the speed of a car, because they can have a range of values.
Continuous data should not be used in an enum. Can you imagine an enum for age?
enum Age { Zero, One, Two, Three, Four, Five, Six }
This is not a good candidate to be placed in an enum because it will need to be continuously updated and amended, leading to a maintenance nightmare.
You should only look to add discrete, highly stable types of data inside an enum.
TypeScript enums offer a powerful way to define and manage sets of related values, though they come with limitations. In this article, we explored techniques to work around these limitations by extending enums using union types, as const
assertions, and more. By applying these methods, you can maintain strong typing while extending the functionality of enums.
Do you have ideas for extending enums that we didn’t cover in this article? Leave your solutions in the comment section below!
Happy hacking!
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowSOLID principles help us keep code flexible. In this article, we’ll examine all of those principles and their implementation using JavaScript.
JavaScript’s Date API has many limitations. Explore alternative libraries like Moment.js, date-fns, and the new Temporal API.
Explore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
2 Replies to "How to extend enums in TypeScript"
>In the above code block, we used an intersection type. The intersection acts like an “or,” which simply means that the DoorState type will either be of type Door or of type DoorFrame.
Isn’t that a union type? Although, the resultant type will let code only compile if the type is used such that only properties in the *intersection* of the types being unioned is accessed.
> Can you extend enums?
> The short answer is no, you can’t extend enums because TypeScript offers no language feature to > extend them.
Depends on what you mean by “extend”… but either way, this sentence is pretty falsy. You might not be able to use an `extends` heritage clause as you would with interfaces or classes, but enums are subject to declaration merging in the same manner as namespaces (both `namespace` and the legacy `module` keyword), and interfaces/classes (at the type-level).
You can also extend the functionality of an enum with static methods, in the same way you would by defining a namespace with the same name as an existing class or function to add types or static methods/properties.
Here’s a brief real world example:
“`ts
// svg path segment commands
enum Command {
MoveToAbs = “M”,
MoveToRel = “m”,
LineToAbs = “L”,
LineToRel = “l”,
// …
}
namespace Command {
// adding types, available as `Command.Absolute` or `Command.Relative`:
export type Absolute = Command.MoveToAbs | Command.LineToAbs;
export type Relative = Command.MoveToRel | Command.LineToRel;
// add runtime functionality with type guards
export function isAbsolute(it: unknown): it is Absolute {
return it === Command.MoveToAbs || it === Command.LineToAbs;
}
export function isRelative(it: unknown): it is Relative {
return it === Command.MoveToRel || it === Command.LineToRel;
}
}
“`