I Like Enums. My Teammate Preferred Literals. TypeScript Let Us Have Both.
DEV Community

I Like Enums. My Teammate Preferred Literals. TypeScript Let Us Have Both.

I recently reviewed a contribution to a shared React module where one developer used const objects instead of enums for accepted prop values. My first reaction was simple: Why not enums?

I like enums for this kind of thing. Maybe too much. For me, an enum is a clear and determined way to declare a list of accepted values. It says: here is the list, this is the source of truth, use these values everywhere.

For example:

enum ButtonVariant {
  Primary = "primary",
  Secondary = "secondary",
  Danger = "danger"
}

Then a component prop can be typed like this:

type ButtonProps = {
  variant: ButtonVariant;
};

And consumers use it like this:

<Button variant={ButtonVariant.Primary} />

I like that. It is explicit. It is easy to search. It is easy to rename. And if the actual value ever needs to change, I can change it in one place. For example, if "primary" needs to become "main", I update the enum declaration:

enum ButtonVariant {
  Primary = "main",
  Secondary = "secondary",
  Danger = "danger"
}

The usages stay connected:

<Button variant={ButtonVariant.Primary} />

No walking through the whole codebase and manually replacing string literals. So, naturally, I asked why the module used a const object instead.

The Const Object Approach

The approach looked something like this:

const ButtonVariant = {
  Primary: "primary",
  Secondary: "secondary",
  Danger: "danger"
} as const;

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

This is a common TypeScript pattern. The object stores the runtime values, and the type extracts a union of those values:

type ButtonVariant = ("primary" | "secondary" | "danger");

Then the prop can be typed like this:

type ButtonProps = {
  variant: ButtonVariant;
};

And consumers can pass literal values directly:

<Button variant="primary" />

The answer from the developer was fair. In React components, literal values are often just nicer to use:

<Button variant="primary" />

No extra import. Readable JSX. Good IntelliSense. Less ceremony for consumers of the component. And honestly, I get it. This is especially true in UI libraries. When someone writes JSX, they usually want a prop to feel lightweight. Importing an enum just to pass one prop can feel a bit heavy.

So there was a small design conflict:

<Button variant={ButtonVariant.Primary} />

vs.

<Button variant="primary" />

Why I Still Wanted Enums

I still wanted enums to be the source of truth. The const object approach is practical, but for me enums still have a few important advantages.

First, enum is a built-in TypeScript language feature. It is not an emulation of enum-like behavior with an object. So if TypeScript improves enums in the future, enum-based code can naturally benefit from that. Objects are still just objects. It is a different paradigm.

Second, I do not see a strong optimization argument against enums here. One of the points was that const object values are simple and can be optimized well by JavaScript engines. That is true, but enum usage is not a real problem for this use case either. At runtime, a string enum access is still just a stable property access:

ButtonVariant.Primary

A const object is also an object with properties. So for a React prop value like this, I do not think this is the place where performance will be decided. In practice, both approaches are simple enough here.

Third, objects can do more than just declare values. They can contain expressions, computed values, or even cause side effects during initialization.

const ButtonVariant = {
  Primary: getPrimaryVariant(),
  Secondary: "secondary",
  Danger: "danger"
} as const;

Maybe sometimes this is useful, but for declaring a fixed list of accepted values, I actually prefer the limitation of enums. With enums, TypeScript keeps the declaration much more strict and obvious.

enum ButtonVariant {
  Primary = "primary",
  Secondary = "secondary",
  Danger = "danger"
}

That is exactly what I want here: a clear list of allowed values.

But I also did not want to make the React component worse to use. Because the other developer had a good point too:

<Button variant="primary" />

This is nice in JSX. No extra import. Easy to read. Autocomplete still helps.

So I started looking for a compromise: Can we declare the accepted values as an enum, but allow consumers to pass either enum members or literal values? Turns out, yes.

The Goal

Given this enum:

enum ButtonVariant {
  Primary = "primary",
  Secondary = "secondary",
  Danger = "danger"
}

I wanted both of these to be valid:

<Button variant={ButtonVariant.Primary} />
<Button variant="primary" />

But this should still be rejected:

<Button variant="random" />

So the prop type should accept:

  • ButtonVariant.Primary | ButtonVariant.Secondary | ButtonVariant.Danger
  • and also: "primary" | "secondary" | "danger"

That became the idea behind SoftEnum. I called it SoftEnum because the enum stays the source of truth, but the API is softer about what it accepts.

The Type

Here is the helper:

export type ExtractEnumValuesAsLiterals<
  T_Enum extends Record<string, string | number>,
  T_Keys extends keyof T_Enum = keyof T_Enum
> = (T_Enum[T_Keys] extends `${infer T_Value}` ? T_Value : never);

export type SoftEnum<
  T_Enum extends Record<string, string | number>,
  T_Keys extends keyof T_Enum = keyof T_Enum
> = (ExtractEnumValuesAsLiterals<T_Enum, T_Keys> | T_Enum[T_Keys]);

Now the component can use the enum as the source of truth:

type ButtonProps = {
  variant: SoftEnum<typeof ButtonVariant>;
};

And both styles are accepted:

<Button variant={ButtonVariant.Primary} />
<Button variant="primary" />

But invalid values are still rejected:

<Button variant="random" />  // Type error

Why Not Just Use the Enum Type?

If the prop is typed directly as the enum:

type ButtonProps = {
  variant: ButtonVariant;
};

Then this is fine:

<Button variant={ButtonVariant.Primary} />

But this is not:

<Button variant="primary" />

Even though the runtime value of ButtonVariant.Primary is "primary", TypeScript still treats the enum member as its own enum member type. That is useful in many cases, but here it makes the JSX API stricter than we want.

Why the Template Literal Inference Works

The interesting part is this:

T_Enum[T_Keys] extends `${infer T_Value}` ? T_Value : never

This extracts the underlying literal value from the enum member type. For string enums:

enum ButtonVariant {
  Primary = "primary",
  Secondary = "secondary"
}

type ButtonVariantValues = ExtractEnumValuesAsLiterals<typeof ButtonVariant>;

The result is:

"primary" | "secondary"

Then SoftEnum combines that with the original enum member types:

type ButtonVariantInput = SoftEnum<typeof ButtonVariant>;

Conceptually, it becomes:

ButtonVariant.Primary | ButtonVariant.Secondary | "primary" | "secondary"

That means users can choose either style.

It Also Works With Numeric Enums

This helper is not only for string enums. For example:

enum Status {
  Active,
  Disabled
}

This works:

const status1: SoftEnum<typeof Status> = Status.Active;  // valid
const status2: SoftEnum<typeof Status> = 0;              // valid
const status3: SoftEnum<typeof Status> = "0";            // invalid

That last line is important. The helper does not turn numeric enum values into strings. It extracts the numeric literal value. So for numeric enums, SoftEnum<typeof Status> accepts:

  • Status.Active | Status.Disabled | 0 | 1

not:

  • "0" | "1"

Narrowing to Specific Enum Keys

The second generic parameter makes it possible to allow only part of an enum.

type PrimaryOrSecondaryOnly = SoftEnum<typeof ButtonVariant, "Primary" | "Secondary">;

Now the accepted values are only:

  • ButtonVariant.Primary | ButtonVariant.Secondary | "primary" | "secondary"

This can be useful when one component supports only a subset of shared enum values.

The Compromise

This gave us a nice middle ground. The library can still declare accepted values with enums:

enum ButtonVariant {
  Primary = "primary",
  Secondary = "secondary",
  Danger = "danger"
}

So the enum remains the source of truth. But consumers can still write lightweight JSX:

<Button variant="primary" />

Or explicit enum-based code:

<Button variant={ButtonVariant.Primary} />

Both are type-safe. Both are valid. And invalid values are still rejected.

Final Thoughts

I still like enums. They are a real TypeScript feature, not just an object pattern. They are explicit, searchable, refactor-friendly, and they give me one clear place where accepted values are declared.

But I also understand why literal values feel better in many React APIs. JSX is supposed to be easy to read and easy to write. Forcing imports for every simple prop value can make a component library feel heavier than it needs to be.

SoftEnum is a small type-level compromise:

export type SoftEnum<
  T_Enum extends Record<string, string | number>,
  T_Keys extends keyof T_Enum = keyof T_Enum
> = (ExtractEnumValuesAsLiterals<T_Enum, T_Keys> | T_Enum[T_Keys]);

It lets library authors keep enums as the source of truth, while component consumers can use simple literals when that feels more natural.

Not every API needs to pick one side forever. Sometimes the nicest developer experience is just letting both styles coexist safely.

Comments

No comments yet. Start the discussion.