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 ]
// ✅ GOOD
const = {
: '/',
: '/about',
: '/work',
: '/uses',
: '/blog',
} as
type InternalLink = (typeof )[keyof typeof ]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 = .().(() => === )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 }
}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 }
}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