Back to patterns
Enum ergonomics without enum limitations

A Better Enum Alternative

This pattern gives you the readability and confidence people usually want from enums, but with enough flexibility for richer variants. A good real-world example is the optimize configuration in @bun-and-butter/sqlite: Optimize.OnExit is a fixed option, while Optimize.Periodically(60 * 60 * 1000) also carries a value. That is exactly where a normal enum starts to break down.

sqlite.ts
/**
 * Run `PRAGMA optimize;` when closing the connection.
 */
export interface OptimizeOnExit {
	/**
	 * Good for short-lived processes like scripts, import jobs, or one-off tools.
	 */
	kind: "on-close";
}

/**
 * Run `PRAGMA optimize=0x10002;` on connect and `PRAGMA optimize;` periodically.
 */
export interface OptimizePeriodically {
	/**
	 * Good for long-running processes like servers and workers.
	 */
	kind: "periodically";
	/**
	 * Interval in milliseconds.
	 */
	interval: number;
}

export type Optimize = OptimizeOnExit | OptimizePeriodically;

/**
 * Controls how `PRAGMA optimize` is scheduled.
 *
 * Use `Optimize.OnExit` for short-lived processes and
 * `Optimize.Periodically(interval)` for long-running ones.
 *
 * @see {@link https://sqlite.org/pragma.html#pragma_optimize | SQLite Docs}
 */
export const Optimize = {
	/**
	 * Run `PRAGMA optimize;` when closing the connection.
	 */
	OnExit: { kind: "on-close" } satisfies OptimizeOnExit,
	/**
	 * Run `PRAGMA optimize=0x10002;` on connect and `PRAGMA optimize;` periodically.
	 */
	Periodically: (interval: number): OptimizePeriodically => ({
		kind: "periodically",
		interval,
	}),
};

When to reach for this pattern

Use it when your API starts enum-like, but one or more variants need payload. This comes up in job scheduling, retry policies, caching strategies, connection modes, and UI state transitions.

If one variant is just a label and another variant needs arguments, you are usually no longer designing a plain enum. The optimize setting in @bun-and-butter/sqlite is one concrete example: one mode is constant, one mode is parameterized, and both still belong to the same small conceptual family. That is where discriminated unions shine.

Example

Here is the same idea in use with @bun-and-butter/sqlite. One configuration uses the fixed variant, the other uses the parameterized one.

demo.ts
import { buildSQLite, Optimize } from "@bun-and-butter/sqlite";

const shortLivedSQLite = await buildSQLite({
	optimize: Optimize.OnExit, // fixed variant
});

const longRunningSQLite = await buildSQLite({
	optimize: Optimize.Periodically(60 * 60 * 1000), // parameterized variant
});

TLDR;

Use a discriminated union to describe the valid shapes, then add a small companion object so the runtime API still feels almost as convenient as enum members.

If you are newer to TypeScript, you can read that as: first define the valid object forms, then offer friendly helpers so nobody has to remember the exact object structure by hand.

Why a normal enum would not be enough

A normal enum is good when every variant is only a named value. Every member stands alone, like Red, Green, and Blue.

That is not what happens in this pattern. One variant might be a single fixed mode, but another variant also needs additional data. In the @bun-and-butter/sqlite example, Optimize.Periodically(60 * 60 * 1000) needs an interval in milliseconds. An enum can name a mode like periodically, but it cannot naturally model Periodically(60 * 60 * 1000) as a typed value constructor.

You could try to split the information apart, for example by using an enum plus some separate interval field elsewhere. But then the connection between the two becomes weaker. TypeScript can no longer express as clearly that the interval is required only when the kind is "periodically".

That is the real advantage of this pattern: the variant and the data it needs travel together as one typed object.

How the pattern works

First, the code defines two interfaces. OptimizeOnExit represents the simple case with only one field: kind: "on-close". OptimizePeriodically represents the richer case with two fields: kind: "periodically" and interval.

Then both shapes are combined into one union type: Optimize = OptimizeOnExit | OptimizePeriodically. Because both variants have a shared kind field with different literal values, TypeScript can narrow correctly when you check that field later. That is why it is called a discriminated union.

Finally, the exported Optimize object acts like a friendly runtime API. Optimize.OnExit gives you the simple variant immediately, and Optimize.Periodically(interval) builds the variant that needs data.

Why this feels so good to use

The idea behind this pattern is inspired by Rust enums. In Rust, a single enum can contain variants that are just named cases and variants that also carry data. This TypeScript version borrows that idea, even though the implementation looks different under the hood.

The call site becomes self-explanatory. Seeing Optimize.OnExit or Optimize.Periodically(60 * 60 * 1000) tells you both which mode is chosen and, when needed, which configuration value belongs to it.

It is also hard to misuse. If someone chooses the periodic mode, the interval is required right there. They do not have to remember to fill a second property somewhere else in a config object.

What makes it feel good in practice is the API shape itself. Some variants are plain constants, some variants are constructor-like helpers, and all of them produce values that belong to one shared, strongly typed union.

Why the companion object matters

Technically, callers could build the objects by hand. They could write { kind: "periodically", interval: 60 * 60 * 1000 }. But that is more fragile, more repetitive, and less discoverable.

The companion object gives the API a clear entry point. Editors can autocomplete Optimize. and immediately show the two valid construction paths. That is a much nicer experience, especially for beginners or for teammates who are seeing the API for the first time.

The small satisfies OptimizeOnExit check is also nice here. It verifies that the constant really matches the declared shape without widening it into a less precise type.