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
declarekeyword 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 constobjects 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.