Repo guidance: Avoid const enums
Const enums are a TypeScript feature which potentially reduces the amount of code generated when using enums, but they have some major downsides.
Background: How const enums work
This is best understood by comparing the JavaScript code output for enum and const enum.
Here's an example of code which declares and uses standard enums with string and numeric values...
enum Color {
red = 'red',
blue = 'blue',
}
let color = Color.red
function setColor(color: Color) {}
setColor(Color.red)
enum LogLevel {
info,
warn,
error,
}
function log(level: LogLevel, message: string) {}
log(LogLevel.error, 'oh no')
enum FooEventIds {
FooAction1 = 'FooAction1',
FooAction2 = 'FooAction2',
}
// This API takes arbitrary IDs, maybe from multiple packages
function addListener(eventId: string, func: Function) {}
addListener(FooEventIds.FooAction1, () => {})...and the corresponding JS output:
var Color
;(function (Color) {
Color['red'] = 'red'
Color['blue'] = 'blue'
})(Color || (Color = {}))
var LogLevel
;(function (LogLevel) {
LogLevel[(LogLevel['info'] = 0)] = 'info'
LogLevel[(LogLevel['warn'] = 1)] = 'warn'
LogLevel[(LogLevel['error'] = 2)] = 'error'
})(LogLevel || (LogLevel = {}))
let color = Color.red
function setColor(color) {}
setColor(Color.red)
function log(level, message) {}
log(LogLevel.error, 'oh no')
var FooEventIds
;(function (FooEventIds) {
FooEventIds['FooAction1'] = 'FooAction1'
FooEventIds['FooAction2'] = 'FooAction2'
})(FooEventIds || (FooEventIds = {}))
function addListener(eventId, func) {}
addListener(FooEventIds.FooAction1, () => {})This is a lot of code output (especially for enums with many values), and none of the unused values can be tree-shaken from bundles in consuming packages.
If you add the const modifier (const enum Color and const enum LogLevel), the JS output is greatly simplified:
let color = 'red' /* Color.red */
function setColor(color) {}
setColor('red' /* Color.red */)
function log(level, message) {}
log(2 /* LogLevel.error */, 'oh no')
function addListener(eventId, func) {}
addListener('FooAction1' /* FooEventIds.FooAction1 */, () => {})Const enums might seem like an appealing way to reduce bundle size in some cases, but they can create problems.
What's the problem?
If a const enum is exported from a package, it interferes with isolatedModules support and transpilers in the consuming code.
// Is this only a type, or is it a value that exists at runtime?
import { Color } from 'some-pkg'
let color = Color.redImagine that a transpiler will convert your TypeScript code into JavaScript, without parsing the typings of the dependencies. That means if you import { Color } from 'some-pkg' in your code, the transpiler doesn't know if the imported identifier is a const enum, interface, or JS object, so the transpiler leaves the import there.
The problem with this is that when the emitted JavaScript is parsed, the named import will fail. In the raw JS output, you expected TS concepts like type and interface, and of course const enum, to be dropped, and all your Color.red references to be replaced with literals, but that can't happen without knowing what the thing was in the first place.
The point of isolated transpilation is quick development, especially in large-scale repos with hundreds or thousands of packages. If you don't need to parse dependency types, you can transpile massive quantities of code, all in parallel, in milliseconds rather than minutes.
The TypeScript documentation has more details about const enum pitfalls and how isolatedModules works.
But they've never caused me problems before!
Here's the rule: you can use const enums in your package; just don't export them. You might have been following this rule all along.
It's also possible that you were breaking the rule and just never heard about issues. Often consumers build their TypeScript project without using isolatedModules, so typings are fully parsed and well known. Or if your project is compiled with isolatedModules, const enums will have the same JS output as standard enums.
Even if a const enum is currently not exported, it's still best to avoid them altogether, to eliminate the risk of another developer exporting the enum sometime in the future and breaking consumers.
What alternatives are there?
There are a variety of alternatives depending on the usage in consuming packages.
Standard enums
The most obvious alternative is removing the const modifier, but this has some significant downsides.
- ✅ Easy to implement
- ✅ Good ergonomics for all scenarios (string values, numeric values, usage as type or value)
- ✅ Doesn't pollute package exports
- ❌ Very large JS output
- ❌ Unused values can't be tree shaken (whether this matters depends on the size of the enum and how it's used)
Union types
Union types are often the best choice for enums with string values, provided that the usage in consuming code is with strongly typed APIs (such as the Color example below).
- ✅ Decent ergonomics for string values when usage is strongly typed (see the
Colorexample below) - ✅ Identical JS output to const enums (usually good for bundle size)
- No JS output for declaration
- String literals in JS output when consumed
- ❌ Bad for numeric values: leads to "magic numbers" and/or awkward extra code
- ❌ If the consuming code passes enum values to a more loosely typed API, requires awkward addition of typed locals to avoid loss of type safety (see the
FooEventIdsexample below)
export type Color = 'red' | 'blue'
// For numeric values, to avoid "magic numbers" in the declaration,
// you have to declare a type for each value
export type LogLevelInfo = 0
export type LogLevelWarn = 1
export type LogLevelError = 2
export type LogLevel = LogLevelInfo | LogLevelWarn | LogLevelError
export type FooEventIds = 'FooAction1' | 'FooAction2'In the consuming package:
import type { Color, LogLevel, LogLevelError, FooEventIds } from 'some-pkg'
// Have to add a type declaration to preserve type safety here
let color: Color = 'red'
// This works well because the setColor API is strongly typed
function setColor(color: Color) {}
setColor('red')
// Union types don't work so well with numeric values
function log(level: LogLevel, message: string) {}
log(2 /*error*/, 'oh no')
const errorLevel: LogLevelError = 2
log(errorLevel, 'oh no')
// This API is loosely typed, so you have to add a typed local to retain validation
function addListener(eventId: string, func: Function) {}
const eventId: FooEventIds = 'FooAction1'
addListener(eventId, () => {})Individual value exports
In cases where the literal values are important, or must be used in contexts where types cannot easily be added, you can export/import individual values. This can be good for bundle size but very awkward for "ergonomics."
- ✅ Decent for bundle size, especially if there are many values and only a few are used (unused values can be tree shaken out)
- ✅ No "magic numbers" for numeric enums
- ❌ Awkward usage ergonomics and type definitions
- ❌ Pollutes package exports with many values
export const ColorRed = 'red'
export const ColorBlue = 'blue'
// If you need a type:
export type Color = typeof ColorRed | typeof ColorBlue
export const LogLevelInfo = 0
export const LogLevelWarn = 1
export const LogLevelError = 2
export type LogLevel =
| typeof LogLevelInfo
| typeof LogLevelWarn
| typeof LogLevelError
export const FooEventIdsFooAction1 = 'FooAction1'
export const FooEventIdsFooAction2 = 'FooAction2'
export type FooEventIds =
| typeof FooEventIdsFooAction1
| typeof FooEventIdsFooAction2In the consuming package:
// The other colors and log levels shouldn't exist in your final bundle
import { ColorRed, LogLevelError, FooEventIdsFooAction1 } from 'some-pkg'
import type { Color, LogLevel } from 'some-pkg'
// With `let`, to avoid the type being expanded to `string` (while retaining the
// ability to assign other colors), you must explicitly declare the type
let color1: Color = ColorRed
function setColor(color: Color) {}
setColor(ColorRed)
function log(level: LogLevel, message: string) {}
log(LogLevelError, 'oh no')
function addListener(eventId: string, func: Function) {}
addListener(FooEventIdsFooAction1, () => {})Dictionary exports
You can also export a dictionary of values. This has good ergonomics, especially if you mainly need the values and not the types, but minimizers likely will not remove unused keys.
- ✅ Same usage ergonomics as const enums, for either string or numeric values
- ✅ Doesn't pollute package exports
- ❌ Moderately large JS output (but smaller than enums)
- ❌ Unused values can't be tree-shaken
For example:
export const Color = {
red: 'red',
blue: 'blue',
} as const // `as const` preserves the types of the keys
// If you need a type:
export type Color = (typeof Color)[keyof typeof Color]
export const LogLevel = {
debug: 0,
info: 1,
warn: 2,
error: 3,
} as const
export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
export const FooEventIds = {
FooAction1: 'FooAction1',
FooAction2: 'FooAction2',
} as const
export type FooEventIds = (typeof FooEventIds)[keyof typeof FooEventIds]In the consuming package:
import { Color, LogLevel, FooEventIds } from 'some-pkg'
// To retain the ability to assign other colors, you must explicitly declare a type
let color1: Color = Color.red
// This also works
let color2: Color = 'red'
function setColor(color: Color) {}
setColor(Color.red)
setColor('red')
function log(level: LogLevel, message: string) {}
log(LogLevel.error, 'oh no')
function addListener(eventId: string, func: Function) {}
addListener(FooEventIds.FooAction1, () => {})Enforcement
The recommended config from @ms-cloudpack/eslint-plugin bans all const enums using the @rnx-kit/no-const-enum rule.
This rule can't provide automatic fixes since determining the "best" fix for each const enum requires manually evaluating how it's used.
Additional resources
From the TypeScript team:
- Const enum pitfalls
isolatedModulesdocumentation.- TypeScript issue #5219: problems caused by const enums in the TS compiler API
- TypeScript issue #30590: why does the TS team advise against const enum usage?
Quote from Daniel Rosenwasser (TypeScript PM):
const enumsare often misused. Sure, they can be great for the internals of a library to get some speed improvements, but they generally shouldn't be used outside of that scope.