Replacing Enums With Unions

2020-09-06

Thanks to sebastienlorber and dagda1 for this explanation. This entry in my blog is just write up of their answers to my questions on Twitter.

I had a change of mind a few times now about this topic. Can we get away with just unions and do we need enums?

While unions always seemed more flexible for the actual types, one drawback I found was that lack of runtime code meant we can’t use them for certain things. In my particular case where I needed them were schemas for validation and some UI components, like select options.

Sample code

In one of the apps, I was working on I had code similar to this.

enum StockType {
  Skis = 'skis',
  Snowboard = 'snowboard',
  Helmet = 'helmet',
  Boots = 'boots',
}

// let's use that to validate some incoming data
const CreateStock = new SimpleSchema({
  type: {
    type: String,
    allowedValues: Object.values(StockTypes),
  },
});

// and then maybe render some select boxes
const Form = () => (
  <Form>
    <select name="type" id="type">
      {
        Object.entries(StockType)
         .map(([label, value]) =>
            <option key={value} value={value}>{label}</option>
        )
      }
    </select>
  </Form>
)

Let’s tackle the schema first. One of the suggestions was to use as const on array, and then get union out of that. It would look something like the following:

// readonly ["skis", "snowboard", "helmet", "boots"]  
const stockTypes = ['skis', 'snowboard', 'helmet', 'boots'] as const;

// and for type checking we can get the union Like
// "skis" | "snowboard" | "helmet" | "boots"
type StockTypes = typeof stockTypes[number];

// Updated schema
const CreateStock = new SimpleSchema({
  type: {
    type: String,
    allowedValues: stockTypes,
  },
});

This works out nicely for the schema. But in UI part we still have a little issue. Our Select options labels would be lowercase, and even might have some unwanted symbols, depending on what is stored in the enum. While it might be ok in some cases it would have to be formatted for the others. This can be done but might be a bit inconvenient.

To tackle this we could use typeof and keyof combo:

const stockTypes = {
  skis: 'Skis',
  snowboard: 'Snowboard',
  helmet: 'Helmet',
  boots: 'Boots',
} as const;

type StockTypes = keyof typeof stockTypes;

// So our schema stays similar to the original version
const CreateStock = new SimpleSchema({
  type: {
    type: String,
    // NOTE: we are using `Object.keys` instead of `Object.values`
    allowedValues: Object.keys(StockTypes),
  },
});

// similar to the schema
const Form = () => (
  <Form>
    <select name="type" id="type">
      {
        Object.entries(StockType)
        // NOTE: we flip the entries
         .map(([value, label]) =>
            <option key={value} value={value}>{label}</option>
        )
      }
    </select>
  </Form>
)

One caveat in the above example is that we flip the object keys and values over way around compared to the enum. But an interesting thing is that it is then possible to assign any value to the key.

const stockTypes = {
  skis: { label: 'Skis' },
  snowboard: { label: 'Snowboard' },
  helmet: { label: 'Helmet' },
  boots: { label: 'Boots' },
  // we could add any data...
} as const;

Verdict

I’m still on the fence on which one is better. Would need more time to try both things in the wild.

While it seems a weird usage of the type system and shift of thinking when using only unions. It works closer to javascript (in my opinion) because we quite often would use simple POJO to mimick ENUM like structures.

But enums are a more familiar construct for a lot of developers coming from other languages and we get more documented way to iterate and use them in the code.

Further reading

dagda1 article about consts