Don’t use TypeScript’s string enums
I’ve written about TypeScript enums in the past, and back then I encouraged the use of enums with string values instead of numeric ones due to the improved type safety and debuggability you get. This is still true—you should avoid numeric enums in TypeScript wherever possible—but I no longer think that you should reach for string enums as your first option.
There’s an even better way of writing enums that avoids the downsides of TypeScript’s enum
construct without having to lose out on their benefits.
The problem with string enums
The main problem with enums are how they interact with the type system. TypeScript generally uses a structural typing system which lets you treat types that have the same “shape” as interchangeable. The following code snippet type checks successfully because both Email
and Post
are supersets of the shape defined in the Entity
type:
interface Entity { id: string; title: string;} interface Email { id: string; title: string; to: string[];} interface Post { id: string; title: string; threadId: string;} function normalizeTitle(entity: Entity) { return entity.title.trim();} normalizeTitle(post);normalizeTitle(email);
This code snippet doesn’t work in most other languages. In Java you would need to explicitly mark Email
and Post
as implementations of the Email
interface.
Enums are the only thing in TypeScript which break out of the structural typing system. They are nominally typed, which means that two enums which otherwise look identical are treated as completely separate and incompatible types by the type checker:
// enum-one.tsexport enum UserRole { ADMIN = 'ADMIN', MODERATOR = 'MODERATOR', NONE = 'NONE',} // enum-two.tsexport enum UserRole { ADMIN = 'ADMIN', MODERATOR = 'MODERATOR', NONE = 'NONE',} // main.tsimport { UserRole as UserRoleOne } from './enum-one';import { UserRole as UserRoleTwo } from './enum-two'; function doSomething(role: UserRoleOne) { // ...} doSomething(UserRoleTwo.ADMIN); // <- error!
This can sometimes be a useful property. Nominal typing of enums means that it’s very hard for your code to “accidentally” type check; you are forced to be very intentional about the exact source of your type. In practice I find these situations pretty limited, and aren’t worth the maintainability burden.
Almost every real-world codebase I’ve worked on is littered with convertUserRoleOneToUserRoleTwo
helper functions. You can very easily accumulate a lot of duplicate enum definitions if you aren’t careful:
- You define an enum in code.
- You define the same enum in your GraphQL schema file that also gets codegenned.
- You use gRPC for inter-service communication, so you define the same enum again in a protocol buffer file which gets codegenned.
- Eventually someone ends up redefining the enum from (1) in code somewhere else for reasons 1
You end result if you wind up with a bunch duplicate enum definitions which are all mutually incompatible. To work around this you either abuse as
type casts or write a bunch of helper functions for safely converting between each enum.
Solution: object enums
Instead of using the enum
keyword, we can create our own enum type by writing out an object literal like so:
const UserRole = { ADMIN: 'ADMIN', MODERATOR: 'MODERATOR', NONE: 'NONE',} as const;
The as const
bit is what’s doing all of the heavy lifting. If you remove that then each field will be inferred as being a string
instead of its literal type. For extra runtime safety you can wrap pass the object to Object.freeze
, and you will likely also want a proper type definition you can use in function signatures:
const UserRole = Object.freeze({ ADMIN: 'ADMIN', MODERATOR: 'MODERATOR', NONE: 'NONE',} as const); type UserRole = (typeof UserRole)[keyof typeof UserRole]; function doSomething(role: UserRole) { // ...}
Why do it this way?
- Compared to a “normal” numeric enum type, you still retain strict typing. A function taking a numeric enum will happily accept any number regardless of whether it’s defined by the enum, whereas both string enums and these “object enums” will only typecheck if you pass a string that’s actually defined inside them.
- Compared to a “normal” string enum type, this approach is structurally typed which means duplicate definitions of the enum are interchangeable. You don’t need to convert between otherwise identical enums.
- The transpiled output is also much better when you do things this way compared to a “normal” string enum type. TypeScript adds a bunch of boilerplate to every
enum
definition that can end up being surprisingly heavy if you have lots of enums. - Compared to a string literal union (my previously preferred solution) this approach offers a much better developer experience. Doc comments attached to each field of the object actually come up in your editor when you hover over a usage, and “go to definition” / “go to references” works correctly.
- String literals are still a tiny bit lighter in the bundle and are marginally more efficient as your program doesn’t need to dereference an object to read any fields, but these are very minor considerations.
Embrace the boilerplate
There’s a bit of ceremony involved in defining your enum—look at those repetitious key/value pairs! You might be tempted to write a little utility like the following:
function makeEnum<T extends string>(values: T[]): Readonly<{ [K in T]: K }> { return values.reduce((acc, val) => { acc[val] = val; return acc; }, Object.create(null)) as { [K in T]: K; } & { [k: string]: T; };} const UserRole = makeEnum(['ADMIN', 'MODERATOR', 'NONE']);type UserRole = (typeof UserRole)[keyof typeof UserRole];
You really shouldn’t do this, because it completely kills the development experience benefits you would have otherwise benefitted from. When you use this helper it’s impossible to attach documentation comments to each enum member, and you can no longer use your editor's jump to definition/references feature. You’d be better off just writing a string literal union type rather than this. Documentation is really important and giving up the ability to properly document your enum is unlikely to be a good idea.
Tools
Lots of tools will output regular enum types by default—GraphQL codegen is one particularly annoying offender. In this section I’ll let you know how to opt out of string enums in favor of object enums in a few common tools.
graphql-codegen
)
GraphQL (The TypeScript plugin options are pretty confusing. You want to pass enumsAsConst: true
, like so:
const config: CodegenConfig = { // ... generates: { './src/__generated__/graphql.ts': { plugins: ['typescript', 'typescript-operations', /* ... */], config: { // ... enumsAsConst: true, }, }, },};
The other enum-related options are not what you want:
enumsAsTypes
will codegen a string literal union (e.g.'ADMIN' | 'MODERATOR' | 'NONE'
)constEnums
(confusingly named very similarly toenumsAsConst
!) will codegen const enum types.
Note that if you specify multiple options then enumsAsTypes
will “win” over the rest and you won’t get any warning or error when running GraphQL codegen.
ts-proto
)
gRPC (Pass enumsAsLiterals=true
as part of your ts-proto
options to codegen const object enums instead of nominal enum types.
openapi-generator
Nothing to do here. A few years ago openapi-generator
codegenned nominal enum types with no opt-out, but as of the end of 2022 there’s a stringEnums
option in the typescript-axios
and typescript-fetch
presets which defaults to false
. This is what you want; if you aren’t getting object enums out from codegen then try updating your version.
Prisma
Nothing to do here—Prisma codegens object enums out of the box.
- Oftentimes it’s because you need the enum in package A, but it’s defined inside package B which itself depends on package A. If you have bad package management processes in place, many engineers will avoid fixing the cycle and just copy/paste the enum into package B.↩