Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 062 / 100

Enums vs unions vs const objects: lessons from production

Three ways to model fixed values.

Expona Admin7 min read

When you need to represent a fixed set of values in TypeScript, you have three main patterns: enums, union types, and const objects. Each approach has different implications for your bundle size, runtime behavior, and how TypeScript treats them at compile time. On Day 62 of building production systems, understanding when to reach for enums vs unions vs const objects becomes a muscle memory decision that affects everything from performance to team readability.

Comparison: Approach vs Trade-off

Let me break down these three patterns side by side so you can see where each one lives in the spectrum.

Enums: Runtime object, not erasable

A TypeScript enum generates actual JavaScript code at runtime. When you write an enum, TypeScript transpiles it into an object that exists in your bundle.

typescript

enum OrderStatus {
  Pending = 'PENDING',
  Confirmed = 'CONFIRMED',
  Shipped = 'SHIPPED',
  Delivered = 'DELIVERED'
}

// TypeScript compiles this to:
// var OrderStatus = {
//   Pending: 'PENDING',
//   Confirmed: 'CONFIRMED',
//   Shipped: 'SHIPPED',
//   Delivered: 'DELIVERED'
// };

The trade-off: you get a real object you can iterate over or look up values on at runtime, but you're paying bundle size for it. Enums also aren't "just JavaScript"—they're a TypeScript construct, so if you're interacting with plain JavaScript libraries or JSON APIs, the enum itself doesn't exist there.

I've built e-commerce platforms where we needed to map order statuses to user-facing labels and icons. An enum felt convenient at first because you could do Object.entries(OrderStatus) to build a dropdown. But when the frontend bundle hit size limits, I realized we were shipping an enum object that could have been a simple string union.

Union types: Zero runtime, simple

A union of literal types compiles to absolutely nothing. TypeScript checks that you're using one of the allowed values, then erases the type entirely.

typescript

type OrderStatus = 'PENDING' | 'CONFIRMED' | 'SHIPPED' | 'DELIVERED';

// Zero runtime cost. This type doesn't exist in compiled JavaScript.

The trade-off: you get type safety and zero bundle overhead, but you have no runtime object to inspect. If you need to iterate over possible values or look them up dynamically, you need to maintain a separate const array or object yourself.

In practice, I reach for this when I'm handling API responses or form validation where the values are already fixed strings in my backend contract. The union type ensures my code only handles valid values, and the TypeScript compiler keeps me honest.

Const objects: Runtime values plus derived union

You define a const object with the values you care about, then derive a union type from it. Best of both worlds, but requires a pattern.

typescript

const OrderStatus = {
  Pending: 'PENDING',
  Confirmed: 'CONFIRMED',
  Shipped: 'SHIPPED',
  Delivered: 'DELIVERED'
} as const;

type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus];
// Equivalent to: 'PENDING' | 'CONFIRMED' | 'SHIPPED' | 'DELIVERED'

// You can iterate at runtime:
Object.entries(OrderStatus).forEach(([key, value]) => {
  console.log(`${key}: ${value}`);
});

// And use it as a type:
function updateOrder(status: OrderStatus) {
  // ...
}

The trade-off: you ship a small runtime object (only as large as your values), but you gain the ability to use it as both a runtime lookup and a type constraint. This is plain JavaScript—no special TypeScript construct—so it plays well with other tools and APIs.

My default: Union types or as const objects

After 5+ years of shipping production systems, I default to one of these two depending on the use case.

Union types are my first choice when the values only flow one direction: from my code to the type system. Think form submissions, route parameters, or API requests where I'm just checking "is this string one of the valid values?" The bundle savings are real, and there's no cognitive overhead—the code is minimal and clear.

typescript

// Typical use case: form submission handler
type PaymentMethod = 'CREDIT_CARD' | 'DEBIT_CARD' | 'WALLET' | 'BANK_TRANSFER';

function processPayment(method: PaymentMethod, amount: number) {
  if (method === 'CREDIT_CARD') {
    // stripe integration
  }
  // ...
}

// Caller ensures they pass a valid value
const userChoice: PaymentMethod = 'WALLET';
processPayment(userChoice, 99.99);

Const objects become my choice when I need a runtime lookup. Building a dropdown menu, rendering a set of chips, logging all valid states for debugging, or exposing the values in an API response. The const object gives me both the type safety and the runtime flexibility without the overhead of an enum.

typescript

// Typical use case: rendering a list of options
const UserRole = {
  Admin: 'ADMIN',
  Moderator: 'MODERATOR',
  User: 'USER',
  Guest: 'GUEST'
} as const;

type UserRole = typeof UserRole[keyof typeof UserRole];

// Build a dropdown from the object
function RoleSelector({ onChange }: { onChange: (role: UserRole) => void }) {
  return (
     onChange(e.target.value as UserRole)}>
      {Object.entries(UserRole).map(([label, value]) => (
        
      ))}
    
  );
}

Enums still work—but they aren't plain JavaScript

I want to be fair: enums have their place, especially in larger teams where the runtime object appeals to people coming from Java or C#. They work fine, and TypeScript's tooling around them is solid. But I'd encourage caution.

The core issue is that enums are a TypeScript-only concept. If you're building a library that external JavaScript consumers might use, an enum looks like magic to them. If you're integrating with a tool that doesn't understand TypeScript deeply (like some linters or bundler plugins), enums can behave unexpectedly.

I hit this in a CRM system where we were exporting a shared package to multiple consumers—some TypeScript, some plain JavaScript. When we used enums, the JavaScript consumers had to import and use the compiled object, which felt awkward. When we switched to const objects, everything just worked because const is native JavaScript.

typescript

// enums.ts
export enum PaymentStatus {
  Pending = 'PENDING',
  Complete = 'COMPLETE'
}

// consumer.js (plain JavaScript)
import { PaymentStatus } from './enums.js';
// PaymentStatus is the compiled object, which works but feels odd

// const-objects.ts
export const PaymentStatus = {
  Pending: 'PENDING',
  Complete: 'COMPLETE'
} as const;

// consumer.js (plain JavaScript)
import { PaymentStatus } from './const-objects.js';
// PaymentStatus is just an object. Natural.

Common mistakes

  • Using enums when a union would do. You ship runtime code you don't need. A union type forces you to ask: "Do I actually need to inspect this at runtime?" If not, use a union.
  • Forgetting as const on const objects. Without it, TypeScript infers the type as string, not the literal. You lose type safety.
  • Mixing enums and unions in the same codebase. It's confusing for team members. Pick a pattern and stick with it. If you must have both, document why.
  • Assuming enums are backward compatible. When you add a new enum member, old compiled code might not handle it gracefully at runtime. Unions force you to think about this explicitly.
  • Using string enums when numeric enums exist. Numeric enums are even trickier at runtime. If you're using enums at all, stick with string enums for clarity.

Key takeaways

  • Union types are zero-cost abstractions. They're the most lightweight option and ideal when you only need type checking, not runtime iteration.
  • Const objects give you the best of both worlds: a small, plain JavaScript runtime object plus a derived type. This is my default when I need runtime access to the values.
  • Enums have trade-offs. They add bundle weight, they're not plain JavaScript, and they're less portable. Use them sparingly, usually only in large team settings where the familiarity outweighs the costs.
  • Be consistent within a codebase. Pick one pattern per project and stick with it. Your team will thank you when onboarding or refactoring.
  • Consider your consumer. If you're building a library or API that non-TypeScript code will consume, favor const objects over enums.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles