Mapping between identical TypeScript enums without the boilerplate
Marcell Toth
Posted on April 14, 2022
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:
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:
- The IDC approcach: You cast everywhere. This is the easiest, but it is dangerous, as enum casts let you do pretty much anything.
- 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: This is very safe for sure, but adds a lot of maintenance overhead, and code (bundle) size for nooooot a lot of good reasons.
- The middle ground: You create a function but cast inside. 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;
};
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;
We have 3 generic args:
- The source enum type, which can be anything (you could add a constraint here if you wanted to).
- 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.
- 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:
Incompatible enums are rejected - with a pretty clear error message even:
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>;
You can see it in action here, including one error it caught:
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
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>;
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.
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
November 29, 2024