24 January, 20255 minute read

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:

Click to copy
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:

Click to copy
// 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:

  1. You define an enum in code.
  2. You define the same enum in your GraphQL schema file that also gets codegenned.
  3. You use gRPC for inter-service communication, so you define the same enum again in a protocol buffer file which gets codegenned.
  4. 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:

Click to copy
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:

Click to copy
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?

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

Click to copy
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 (graphql-codegen)

The TypeScript plugin options are pretty confusing. You want to pass enumsAsConst: true, like so:

Click to copy
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:

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.

gRPC (ts-proto)

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.



  1. 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.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com