Mapping between identical TypeScript enums without the boilerplate

marcelltoth

Marcell Toth

Posted on April 14, 2022

Mapping between identical TypeScript enums without the boilerplate

TypeScript's enums are weird. They are the only piece in TS (as far as I'm aware) that doesn't follow the structural / duck typing pattern. If you prefer enums in place of string unions, I'm sure you've encountered the following:

Image description

You have two enums which contain the same members, yet TS doesn't realize they are compatible. Now I see three main patterns people utilize to resolve the problem:

  1. The IDC approcach: You cast everywhere. This is the easiest, but it is dangerous, as enum casts let you do pretty much anything.
  2. The purist: The types are different because "they represent different things", so you ignore the similarity and create a mapper function (or object) and use it where needed: Image description This is very safe for sure, but adds a lot of maintenance overhead, and code (bundle) size for nooooot a lot of good reasons.
  3. The middle ground: You create a function but cast inside. Image description This looks better than approach no 1 on paper, as you only cast once, but you really are still prone to the same danger: If the two enums diverge at any point, you won't get a single warning from TS.

Type safety without the boilerplate

I was thinking, can we somehow get the type-safety of approach #2 while also retaining the purity and bundle size of #3? It turns out, we can.

On the JS level this 3rd approach is trivial, your mapper functions are essentially x => x.

So now it's "just" the matter of adding TS constraints to make sure we are only allowed to map between identical enums.

The implementation

The core

My current solution only works with symmetrical (same key and values) enums for simplicity, although it could very well be extended. So let's define what is a symmetrical enum first:



type SymmetricalEnum<TEnum> = {
  [key in keyof TEnum]: key;
};


Enter fullscreen mode Exit fullscreen mode

Then - here is the trick - we can define what the result value of a mapping will be, this is essentially doing the mapping on the TS metalanguage level, with all of its benefits:



type MapperResult<
  TSourceEnumObj,
  TDestEnumObj extends SymmetricalEnum<TSourceEnumObj>,
  TSourceValue extends keyof TSourceEnumObj
> = TDestEnumObj extends { [key in TSourceValue]: infer TResult } ? TResult : never;


Enter fullscreen mode Exit fullscreen mode

We have 3 generic args:

  1. The source enum type, which can be anything (you could add a constraint here if you wanted to).
  2. The destination enum type, which needs to extend the source enum. This is the core of the type-safety logic. If you try to map to an enum which is not a superset of the source this line will yell here.
  3. The type of actual input value given to the mapper function. This is then used in a conditional statement to essentially "pull" the corresponding value from the destination type. If you need to read up on Conditional Types I suggest starting with the documentation.

You can use the above on it's own if you need a mapping on the types-level, see this magic for example:

Image description

Incompatible enums are rejected - with a pretty clear error message even:
Image description

Creating mapper functions

Now we are ready to create a higher order function that auto-generates mapper functions, utilizing this magic.



const createEnumMapperFunction =
  <TSourceEnumObj, TDestEnumObj extends SymmetricalEnum<TSourceEnumObj>>(from: TSourceEnumObj, to: TDestEnumObj) =>
  <TInput extends keyof TSourceEnumObj>(value: TInput) =>
    value as MapperResult<TSourceEnumObj, TDestEnumObj, TInput>;


Enter fullscreen mode Exit fullscreen mode

You can see it in action here, including one error it caught:

Image description

And this is it! Now you can replace all your switch statements with a call to createEnumMapperFunction, or replace your casts with a type safe version like this above. Feel free to tweak it to your needs, like adding support for non-symmetrical enums or other cases.

Final code

For those that have the StackOverflow keyboard Image description

here is the full code, and a TS Playground with examples:



type SymmetricalEnum<TEnum> = {
  [key in keyof TEnum]: key;
};

type MapperResult<
  TSourceEnumObj,
  TDestEnumObj extends SymmetricalEnum<TSourceEnumObj>,
  TSourceValue extends keyof TSourceEnumObj
> = TDestEnumObj extends { [key in TSourceValue]: infer TResult } ? TResult : never;

const createEnumMapperFunction =
  <TSourceEnumObj, TDestEnumObj extends SymmetricalEnum<TSourceEnumObj>>(from: TSourceEnumObj, to: TDestEnumObj) =>
  <TInput extends keyof TSourceEnumObj>(value: TInput) =>
    value as MapperResult<TSourceEnumObj, TDestEnumObj, TInput>;


Enter fullscreen mode Exit fullscreen mode

I hope you find this useful some day. Let me know in the comments if you did, and if you have built an improved version I'd also love to know.

💖 💪 🙅 🚩
marcelltoth
Marcell Toth

Posted on April 14, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related