Back to blog

Enums in TypeScript, the Right Way

Don't use enums in TypeScript; do this instead.

Primer

Everyone has written a think piece on why TypeScript enums are bad, so I won't be repeating that here. For the uninitiated, I'll leave you with this article that explains most of the quirks and foot-guns.

Instead, I'll focus on practical examples of what you should use, and why these patterns are better.

Immutable Constants

You can make an object's values literal and readonly (to TypeScript) with as const.

// ❌ BAD
const  = {
  : '/',
  : '/about',
  : '/work',
  : '/uses',
  : '/blog',
}

type MutableInternalLink = (typeof )[keyof typeof ]
type MutableInternalLink = string
// ✅ GOOD const = { : '/', : '/about', : '/work', : '/uses', : '/blog', } as type InternalLink = (typeof )[keyof typeof ]
type InternalLink = "/" | "/about" | "/work" | "/uses" | "/blog"

MutableInternalLink resolves to string, because the object is mutable to TypeScript.

Meanwhile InternalLink resolves to a literal union ('/' | '/about' | ...), because the object is treated as immutable by TypeScript.

Runtime immutability?

Technically INTERNAL_LINK is still mutable at runtime, because as const is a TypeScript-only construct.

If you need true runtime immutability, you can use Object.freeze() for shallow immutability, or a library like Immer for deep immutability. For most cases, this is overkill.

I recommend writing TypeScript that you can actually trust:

  • no type assertions
  • treat type errors as failures in CI

This philosophy, along with as const, will take you far with a minimal amount of friction.

Narrowing from a primitive type

TypeScript won't narrow a string to a string-enum member without an assertion, which is inherently brittle. You can, however, safely narrow a string primitive to a string literal without an assertion.

const  = {
  : '/',
  : '/about',
  : '/work',
  : '/uses',
  : '/blog',
} as 

const  = .

const safeCurrentUrl = .().(() =>  === )
const safeCurrentUrl: "/" | "/about" | "/work" | "/uses" | "/blog" | undefined
if () { // hooray, handle accordingly }

Most modern tooling offers route type safety (TanStack router, NextConfig['typedRoutes']), which may render this specific example moot. The point, however, is not specifically about route type safety.

The main takeaway is how to narrow a string to a specific value without type assertions.

Literal Types + Switch = 💖

An exhaustive switch statement is a great way to handle a literal type.

type  = 'foo' | 'bar'

function (: ) {
  switch () {
    case 'foo':
      break

    case 'bar':
      break

    default:
      value satisfies never
value: never
} }

Let's say this Value type is backend-driven, for example an OpenAPI schema, tRPC, etc.

The next time we fetch this schema, there's a new member, baz. Our exhaustive switch statement will now error because we didn't handle the new baz case.

type  = 'foo' | 'bar' | 'baz'

function (: ) {
  switch () {
    case 'foo':
      break

    case 'bar':
      break

    default:
      value satisfies never
Type '"baz"' does not satisfy the expected type 'never'.
value: "baz"
} }

We offload the burden of maintaining the schema-driven logic to the TypeScript compiler.

For simplicity's sake, we only discussed a primitive string type, but you can apply this pattern to discriminated unions too. Radical!

Erasable Syntax Only

TypeScript 5.8 introduced erasableSyntaxOnly, disallowing enums (and other TS-specific syntax).

I recommend enabling this flag, unless you're reliant on external libraries that use enums.

Generally speaking, it's ideal to enforce conventions with linters or config, rather than relying on tribal knowledge and PR review.

Last updated

April 15, 2026