~/posts/the-case-for-typescript-enums
The case for TypeScript enums
Replacing TypeScript enums with as const objects sounds harmless until two domains happen to use the exact same values. Structural typing only sees the strings, not the meaning behind them, and it’s exactly how a bug slips through.
Say you model user accounts and memberships separately:
const UserStatus = { ACTIVE: 'ACTIVE', DISABLED: 'DISABLED',} as consttype UserStatus = (typeof UserStatus)[keyof typeof UserStatus]
const MembershipStatus = { ACTIVE: 'ACTIVE', DISABLED: 'DISABLED',} as const
type MembershipStatus = (typeof MembershipStatus)[keyof typeof MembershipStatus]
function markUserLoginAllowed(status: UserStatus) { if (status === UserStatus.ACTIVE) { // DB: update users set can_login = true }}
function syncMembership(status: MembershipStatus) { markUserLoginAllowed(status)}By name these look like different domain types. But in TypeScript, they both collapse to the same union: 'ACTIVE' | 'DISABLED'. TypeScript is doing the structural typing part exactly as designed: it sees the same values and treats them as compatible.
These domains obviously aren’t the same thing. A membership status flowing into account login logic should be a deliberate conversion, not an accidental assignment. When using native enums instead, the mismatch stops at compile time:
enum UserStatus { ACTIVE = 'ACTIVE', DISABLED = 'DISABLED',}
enum MembershipStatus { ACTIVE = 'ACTIVE', DISABLED = 'DISABLED',}
function markUserLoginAllowed(status: UserStatus) { if (status === UserStatus.ACTIVE) { // update users table }}
function syncMembership(status: MembershipStatus) { markUserLoginAllowed(status) // error: MembershipStatus is not assignable to UserStatus}The compiler blocks code that looks plausible by shape but is wrong by meaning. In a small file, you’d probably catch this in review. In a larger system, I’d rather have the type checker stop it for me.
Branded types prove the point
Branded types are often what people use instead. They work, but they also prove the point: the moment structural typing gets too permissive, teams start rebuilding nominality by hand.
type UserStatus = ('ACTIVE' | 'DISABLED') & { readonly __brand: 'UserStatus' }type MembershipStatus = ('ACTIVE' | 'DISABLED') & { readonly __brand: 'MembershipStatus'}
const asUserStatus = (value: 'ACTIVE' | 'DISABLED') => value as UserStatusconst asMembershipStatus = (value: 'ACTIVE' | 'DISABLED') => value as MembershipStatus- You’re declaring the value union plus the brand shape, and usually helper functions too
- You now have to maintain the branding pattern and make sure your team does too
- You don’t get a runtime enum object, just raw strings.
Distinct string enums give you that boundary without all the extra work. Branded types still fit when the value is open-ended (like a branded UserId string) or you want nominal identity without a runtime object. But for a closed set of domain states, enums are much easier.
Where this pays for itself
On a team, shared string unions make reuse cheap. Two packages expose 'ACTIVE' | 'DISABLED', then somebody wires them together, and the type checker shrugs because the values line up. Distinct enums force the translation to be explicit. If one domain really should map to the other, you write the conversion and make the intent obvious.
I wouldn’t use enums for every constants object. But a shared workflow state that gates critical behavior? That’s where I want the type checker to be stubborn.