Mapping between identical TypeScript enums without the boilerplate
Tags: typescript
Published: 2022-04-14 13:18:43

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u1l5i0gdmqwco9awuy9c.png)
 
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bdyj1d1h3d2lak0e3sdq.png)
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vaiyixeqyumg5q4wzeva.png)
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:

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

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:

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

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](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html).

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rb0q5v8xwlutjinlt6rx.png)

Incompatible enums are rejected - with a pretty clear error message even:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zt00ue8yyz1z2havlx1s.png)

#### Creating mapper functions

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

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

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

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/153a0cy09wz3d6z4dld7.png)

**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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dvke5scasjdg9ywgf7oz.png)
 
here is the full code, and a [TS Playground with examples](https://tinyurl.com/yt69pk9h):

```ts
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>;
```

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.