DEV Community

Cover image for I Like Enums. My Teammate Preferred Literals. TypeScript Let Us Have Both.
NickM
NickM

Posted on

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"
}
Enter fullscreen mode Exit fullscreen mode

Then a component prop can be typed like this:

type ButtonProps =
{
    variant: ButtonVariant;
};
Enter fullscreen mode Exit fullscreen mode

And consumers use it like this:

<Button variant={ButtonVariant.Primary} />
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

The usages stay connected:

<Button variant={ButtonVariant.Primary} />
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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"
);
Enter fullscreen mode Exit fullscreen mode

Then the prop can be typed like this:

type ButtonProps =
{
    variant: ButtonVariant;
};
Enter fullscreen mode Exit fullscreen mode

And consumers can pass literal values directly:

<Button variant="primary" />
Enter fullscreen mode Exit fullscreen mode

The answer from the developer was fair.

In React components, literal values are often just nicer to use:

<Button variant="primary" />
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

vs.

<Button variant="primary" />
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

I wanted both of these to be valid:

<Button variant={ButtonVariant.Primary} />
<Button variant="primary" />
Enter fullscreen mode Exit fullscreen mode

But this should still be rejected:

<Button variant="random" />
Enter fullscreen mode Exit fullscreen mode

So the prop type should accept:

ButtonVariant.Primary
    |
ButtonVariant.Secondary
    |
ButtonVariant.Danger
Enter fullscreen mode Exit fullscreen mode

and also:

"primary"
    |
"secondary"
    |
"danger"
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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

type ButtonProps =
{
    variant: SoftEnum<typeof ButtonVariant>;
};
Enter fullscreen mode Exit fullscreen mode

And both styles are accepted:

<Button variant={ButtonVariant.Primary} />
<Button variant="primary" />
Enter fullscreen mode Exit fullscreen mode

But invalid values are still rejected:

<Button variant="random" /> // Type error
Enter fullscreen mode Exit fullscreen mode

Why Not Just Use the Enum Type?

If the prop is typed directly as the enum:

type ButtonProps =
{
    variant: ButtonVariant;
};
Enter fullscreen mode Exit fullscreen mode

Then this is fine:

<Button variant={ButtonVariant.Primary} />
Enter fullscreen mode Exit fullscreen mode

But this is not:

<Button variant="primary" />
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

The result is:

"primary"
    |
"secondary"
Enter fullscreen mode Exit fullscreen mode

Then SoftEnum combines that with the original enum member types:

type ButtonVariantInput = SoftEnum<typeof ButtonVariant>;
Enter fullscreen mode Exit fullscreen mode

Conceptually, it becomes:

ButtonVariant.Primary
    |
ButtonVariant.Secondary
    |
"primary"
    |
"secondary"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

This works:

const status1: SoftEnum<typeof Status> = Status.Active; // valid

const status2: SoftEnum<typeof Status> = 0;             // valid

const status3: SoftEnum<typeof Status> = "0";           // invalid
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

not:

"0" | "1"
Enter fullscreen mode Exit fullscreen mode

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"
>;
Enter fullscreen mode Exit fullscreen mode

Now the accepted values are only:

ButtonVariant.Primary
    |
ButtonVariant.Secondary
    |
"primary"
    |
"secondary"
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

So the enum remains the source of truth.

But consumers can still write lightweight JSX:

<Button variant="primary" />
Enter fullscreen mode Exit fullscreen mode

Or explicit enum-based code:

<Button variant={ButtonVariant.Primary} />
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
mongweb profile image
Mong Swe Chai Marma

This is such a practical discussion! 🙌

I've faced this exact debate in my own projects. As a frontend developer who builds a lot of UI components, I can see both sides:

Enums are great for:

  • Clear documentation
  • Easy refactoring
  • Type safety across the codebase

String literals with const are better for:

  • Simpler JSX syntax
  • No imports needed in every file
  • Easier for designers/non-devs to understand

I love your point about TypeScript allowing both approaches. Have you considered using TypeScript's as const with union types as a middle ground? Something like:

const ButtonVariants = {
  Primary: 'primary',
  Secondary: 'secondary',
} as const;

type ButtonVariant = typeof ButtonVariants[keyof typeof ButtonVariants];
Enter fullscreen mode Exit fullscreen mode

This gives you the best of both worlds!

What's your take on this approach? Really enjoyed this thoughtful post! 💯