Skip to main content

TypeScript Enums for Defining Named Constants

You're debugging a payment processing bug where orders are stuck in "pending" status. Somewhere in your codebase, someone typed "PENDING" while another part checks for "pending". Same word, different case, broken feature. Sound familiar?

This is exactly why TypeScript enums exist. Instead of scattering magic strings and numbers throughout your code, enums let you define a set of named constants with meaningful labels. You reference PaymentStatus.Pending instead of guessing whether it's "PENDING", "pending", or "Pending".

Here's what that looks like:

enum PaymentStatus {
Pending = "PENDING",
Processing = "PROCESSING",
Completed = "COMPLETED",
Failed = "FAILED",
}

Now you can use PaymentStatus.Pending everywhere. No typos, full autocomplete support, and if you ever need to change the underlying value, you update it in one place.

In this article we'll cover:

  • Ambient vs non-ambient enums
  • Enum mapping (bidirectional and unidirectional)
  • Constant and computed enum member values
  • How enums translate to JS
  • Const enums vs regular enums
  • Numeric enums and heterogeneous enums
  • When you might not need enums (modern alternatives)
  • Common pitfalls to avoid
  • Real-world use cases

Ambient Enums in TypeScript

Ambient enums are a special type of enum used when the enum values are defined externally, often in a separate library or environment, and need to be declared in TypeScript to enable type-checking.

Ambient vs. Non-Ambient Enums

  • Ambient Enums: Declared with the declare keyword and exist only at compile time. They don't generate any JavaScript output.
  • Non-Ambient Enums: Declared normally and are fully compiled into JavaScript.

Here's an example:

// Ambient Enum
declare enum ExternalApiStatus {
Start = 1,
Stop = 2,
}

// Non-Ambient Enum
enum InternalApiStatus {
Start = 1,
Stop = 2,
}

Limitations of Ambient Const Enums

Ambient enums cannot include computed or non-literal values:

declare const enum ExternalConfig {
Value = Math.random(), // Error: Ambient const enums can only have literal values
}

Enum Mapping in TypeScript

TypeScript supports both unidirectional and bidirectional mappings for enums, which allow you to look up values either by name or by value.

Unidirectional Mapping

In a string enum, you can map from the key to its value:

enum OrderStatus {
Pending = "PENDING",
Approved = "APPROVED",
}

console.log(OrderStatus.Pending); // Output: "PENDING"

Bidirectional Mapping

For numeric enums, TypeScript automatically creates both forward and reverse mappings:

enum HttpStatusCode {
OK = 200,
NotFound = 404,
}

console.log(HttpStatusCode.OK); // Output: 200
console.log(HttpStatusCode[200]); // Output: "OK"

This bidirectional behavior is unique to numeric enums and doesn't apply to string enums.

Constant and Computed Enum Member Values

Enum members can be either constant or computed. Constant members are known at compile-time, while computed members are evaluated at runtime.

Constant Members

Constant members can be explicitly or implicitly initialized:

enum Priority {
Low = 1, // Explicit
Medium, // Implicit (auto-increment from Low)
High = 10, // Explicit
}
console.log(Priority.Medium); // Output: 2

Computed Members

Computed members require runtime evaluation:

enum Timeout {
Short = 1000,
Long = Short * 5, // Computed
}
console.log(Timeout.Long); // Output: 5000

String Enum Initialization

String enum members must always be explicitly initialized:

enum LogLevel {
Debug = "DEBUG",
Info = "INFO",
Error = "ERROR",
}

How Enums Translate to JavaScript

At runtime, enums translate into plain JavaScript objects. Here's what happens:

Numeric Enums

For a numeric enum:

enum PermissionLevel {
Read = 1,
Write,
}

console.log(PermissionLevel.Read); // Output: 1

It translates to:

var PermissionLevel;
(function (PermissionLevel) {
PermissionLevel[(PermissionLevel["Read"] = 1)] = "Read";
PermissionLevel[(PermissionLevel["Write"] = 2)] = "Write";
})(PermissionLevel || (PermissionLevel = {}));

Note the reverse mapping created for numeric enums.

String Enums

For string enums, only forward mappings are generated:

enum ApiResponseType {
Success = "SUCCESS",
Error = "ERROR",
}

Translates to:

var ApiResponseType = {
Success: "SUCCESS",
Error: "ERROR",
};

Const Enums vs Regular Enums

Here's where things get interesting. TypeScript offers two types of enums with very different runtime behavior.

Regular Enums

Regular enums generate JavaScript code at runtime:

enum FeatureFlag {
DarkMode = "DARK_MODE",
BetaFeatures = "BETA_FEATURES",
}

This compiles to a full JavaScript object (as shown earlier). The runtime object exists in your bundle, which means:

  • You can iterate over enum values
  • You get reverse mapping for numeric enums
  • Your bundle size increases slightly

Const Enums

Const enums are completely inlined at compile time:

const enum FeatureFlag {
DarkMode = "DARK_MODE",
BetaFeatures = "BETA_FEATURES",
}

const currentFlag = FeatureFlag.DarkMode;

This compiles to:

const currentFlag = "DARK_MODE"; // Inlined, no enum object created

When to use const enums:

  • You want smaller bundle sizes
  • You don't need to iterate over enum values
  • You don't need reverse mapping
  • You're using the enum purely for compile-time type safety

When to use regular enums:

  • You need to iterate over all values
  • You need reverse mapping (for numeric enums)
  • You're passing enums across module boundaries
  • You need runtime enum objects for validation

Keep in mind that const enums have limitations. If you're using isolatedModules (common in projects with Babel or other transpilers), const enums can cause issues.

Numeric Enums

Numeric enums are the most commonly used enums in TypeScript. By default, numeric enums auto-increment:

enum ConnectionState {
Disconnected = 0,
Connecting,
Connected,
}
console.log(ConnectionState.Connecting); // Output: 1

Fully Initialized Numeric Enums

You can explicitly assign values to all members:

enum HttpMethod {
GET = 1,
POST = 2,
PUT = 3,
DELETE = 4,
}
console.log(HttpMethod.POST); // Output: 2

Reverse Mapping

Numeric enums support reverse mapping, allowing you to look up a key by its value:

enum ErrorCode {
NotFound = 404,
ServerError = 500,
}

console.log(ErrorCode[404]); // Output: "NotFound"

Heterogeneous Enums

Heterogeneous enums mix numeric and string values in the same enum. While less common, they provide flexibility:

enum MixedResponse {
No = 0,
Yes = "YES",
}
console.log(MixedResponse.No); // Output: 0
console.log(MixedResponse.Yes); // Output: "YES"

When to use: Heterogeneous enums can be useful when you're working with legacy APIs that return mixed types, but they can make code harder to maintain. If you're starting fresh, stick with either numeric or string enums.

3 Real-World Examples of Enums

Let's look at some situations where enums make your life easier:

1. Handling User Roles

Managing user permissions can get messy fast. Enums make it super clear who can do what.

enum UserRole {
Admin = "ADMIN",
Editor = "EDITOR",
Viewer = "VIEWER",
}

function canEditContent(role: UserRole): boolean {
return role === UserRole.Admin || role === UserRole.Editor;
}

// Example usage:
console.log(canEditContent(UserRole.Admin)); // Output: true
console.log(canEditContent(UserRole.Viewer)); // Output: false

No more wondering if "admin" is supposed to be uppercase, lowercase, or something else. It's always UserRole.Admin.

2. Standardizing API Statuses

If your app talks to an API (and let's face it, most do), enums are fantastic for making sure your responses are consistent.

enum ApiResponseStatus {
Success = "SUCCESS",
Error = "ERROR",
Loading = "LOADING",
}

function displayApiResponse(status: ApiResponseStatus): void {
switch (status) {
case ApiResponseStatus.Success:
console.log("Data fetched successfully!");
break;
case ApiResponseStatus.Error:
console.log("There was an error fetching the data.");
break;
case ApiResponseStatus.Loading:
console.log("Data is still loading...");
break;
}
}

With this setup, you're not just winging it with random strings like "success". You have a clear, standard way to handle every response.

3. Switching Themes in Your App

Want to add a dark mode or high-contrast theme? Enums make it easy to manage all your app's themes in one place.

enum AppTheme {
Light = "light",
Dark = "dark",
HighContrast = "high-contrast",
}

function applyTheme(theme: AppTheme): void {
console.log(`Applying ${theme} theme...`);
document.body.className = theme;
}

// Example usage:
applyTheme(AppTheme.Dark); // Output: Applies the dark theme

Switching themes becomes as simple as calling applyTheme(AppTheme.Dark). No more accidental typos like "darcc".

When You Might Not Need Enums

Here's a plot twist: you might not need enums at all. Modern TypeScript offers alternatives that are more aligned with JavaScript and can avoid some of enum's quirks.

Objects with as const

Instead of an enum, you can use a plain object with a const assertion:

const PaymentStatus = {
Pending: "PENDING",
Processing: "PROCESSING",
Completed: "COMPLETED",
Failed: "FAILED",
} as const;

type PaymentStatus = (typeof PaymentStatus)[keyof typeof PaymentStatus];

Advantages:

  • Stays closer to JavaScript (no TypeScript-specific syntax at runtime)
  • Smaller compiled output
  • You can still iterate over values: Object.values(PaymentStatus)
  • Works seamlessly with isolatedModules

When to use this instead of enums:

  • You're working in a codebase that prefers JavaScript-aligned patterns
  • You want the smallest possible bundle size
  • You don't need reverse mapping

Union Types

For simple cases, a union type might be all you need:

type PaymentStatus = "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED";

function processPayment(status: PaymentStatus) {
// TypeScript ensures you only use valid statuses
}

Advantages:

  • Extremely simple
  • No runtime code at all
  • Perfect for function parameters and return types

Drawbacks:

  • No autocomplete for values (you have to remember the strings)
  • No single source of truth for the list of values

When Enums Are Still the Right Choice

Enums shine when you need:

  • A centralized list of constants with autocomplete
  • Reverse mapping (numeric enums only)
  • A runtime object you can iterate over
  • Clear semantic meaning in your code

If you're working with a team that's comfortable with enums and you need these features, stick with them. The "best" approach depends on your project's needs.

Common Pitfalls to Avoid

Enums can trip you up if you're not careful. Here are the gotchas I see most often:

1. Numeric Enums Accept Any Number

This one catches people off guard. TypeScript will let you assign any number to a numeric enum parameter:

enum StatusCode {
OK = 200,
NotFound = 404,
}

function handleStatus(code: StatusCode) {
console.log(code);
}

handleStatus(999); // No error! But 999 isn't a valid StatusCode

TypeScript 5.0+ improved this slightly, but it's still not fully type-safe. If you need strict validation, use string enums or add runtime checks.

2. Reverse Mapping Can Confuse Iteration

When you iterate over a numeric enum, you get both keys and values:

enum Priority {
Low = 1,
High = 2,
}

Object.keys(Priority).forEach((key) => {
console.log(key);
});
// Output: "1", "2", "Low", "High"

If you only want the names, filter out numeric keys:

Object.keys(Priority)
.filter((key) => isNaN(Number(key)))
.forEach((key) => {
console.log(key); // Output: "Low", "High"
});

3. String Values Aren't Automatically Assignable

This trips up developers coming from other languages:

enum UserRole {
Admin = "ADMIN",
}

function checkRole(role: UserRole) {
// ...
}

checkRole("ADMIN"); // Error: Argument of type '"ADMIN"' is not assignable to parameter of type 'UserRole'

You must use UserRole.Admin, not the string "ADMIN". If you're accepting user input, you'll need runtime validation to convert strings to enum values.

4. Heterogeneous Enums Are Confusing

Mixing numbers and strings in one enum makes your code harder to reason about:

enum MixedEnum {
Yes = 1,
No = "NO",
}

Pick one type and stick with it. Your future self will thank you.

5. Const Enums Break with isolatedModules

If you're using Babel, ts-loader, or other tools that compile files in isolation, const enums can fail because they require cross-file information to inline values. Regular enums work fine in these scenarios.

How Enums Work with Convex

Convex schemas don't support enums directly, but you can simulate their use with Convex by validating the values in your mutation functions using a union of literals:

enum OrderStatus {
Pending = "PENDING",
Shipped = "SHIPPED",
Delivered = "DELIVERED",
}

const orderStatusValidator = v.union(
v.literal(OrderStatus.Pending),
v.literal(OrderStatus.Shipped),
v.literal(OrderStatus.Delivered)
);

// Or more concisely
//
// const orderStatusValidator = v.union(
// ...Object.values(OrderStatus).map(v.literal)
// );

// Define schema
const orderSchema = defineTable({
status: orderStatusValidator,
orderId: v.string(),
// other fields...
});

// Use enum in mutation
export const updateOrderStatus = mutation({
args: {
orderId: v.id("orders"),
status: orderStatusValidator,
},
handler: async (ctx, args) => {
await ctx.db.patch(args.orderId, {
status: args.status,
});
},
});

This ensures consistent and type-safe values for database fields or API responses.

Using Enums Effectively in TypeScript

Enums are powerful when used correctly, but they're not always the right tool. Here's my rule of thumb:

  • Use string enums when you need a centralized list of constants with autocomplete support
  • Use const enums when you want the smallest bundle and don't need runtime features
  • Consider as const objects when you want JavaScript alignment and flexibility
  • Use union types for simple, one-off type restrictions

Whatever you choose, keep it consistent across your codebase. Mixed approaches create confusion.

With Convex's TypeScript-first approach, you can define enums or simulate them with discriminated unions to ensure your backend is robust and consistent. So next time you're tempted to type "admin" or "pending" for the 10th time, think about using an enum or one of its modern alternatives instead.