I'm not happy about the idea of trying to fool the compiler into generating reverse mappings for string enums by using that <any>
type assertion. The reverse string mappings are omitted on purpose, as @RyanCavanaugh said in a relevant GitHub issue:
If we provided the reverse map automatically (which, by the way, is not necessarily non-ambiguous!), then you'd have no way to distinguish keys from values unless there was a completely separate object created at runtime. It is trivial to write a function that constructs the reverse map if you want it; our goal is to emit as little code as possible so it makes sense to just emit the data you need (to keep code size down) and let you construct the reverse map on an as-needed basis.
I think if you continue using that trick, you will find yourself needing to do assertions like you're doing, and you shouldn't be surprised if some future version of TypeScript breaks it entirely.
But if a development lead for TypeScript says "it is trivial to write a function that constructs the reverse map", then we can try that and see how it goes. So how would you go about that? At runtime you really just have to iterate through the enum object entries and produce a new object with the keys and values switched. And if you want both the forward and reverse map in the same object, you can merge the properties from the regular enum into the reversed one:
type Entries<T extends object> = { [K in keyof T]: [K, T[K]] }[keyof T]
function reverseEnum<E extends Record<keyof E, string | number>>(
e: E
): { [K in E[keyof E]]: Extract<Entries<E>, [any, K]>[0] };
function reverseEnum(
e: Record<string | number, string | number>
): Record<string | number, string | number> {
const ret: Record<string | number, string | number> = {};
Object.keys(e).forEach(k => ret[e[k]] = k);
return ret;
}
function twoWayEnum<E extends Record<keyof E, string | number>>(e: E) {
return Object.assign(reverseEnum(e), e);
}
The signature for reverseEnum()
involves a bit of type juggling. The type function Entries<T>
turns an object type T
into a union of key value pairs., e.g., Entries<{a: string, b: number}>
evaluates to ["a",string] | ["b",number]
. Then the return type of reverseEnum()
is a mapped type whose keys come from the enum values, and whose values come from the key for corresponding entry by extracting it. Let's see if it works:
enum AttackType {
MELEE = 'close',
RANGED = 'far'
}
const TwoWayAttackType = twoWayEnum(AttackType);
// const TwoWayAttackType = {
// close: "MELEE";
// far: "RANGED";
// } & typeof AttackType
// might as well make a type called TwoWayAttackType also,
// corresponding to the property values of the TwoWayAttackType object
type TwoWayAttackType = AttackType | keyof typeof AttackType
console.log(TwoWayAttackType.close); // "MELEE"
console.log(TwoWayAttackType[TwoWayAttackType.far]) // "far"
You can see that the value TwoWayAttackType
has the same type as the AttackType
enum constant, with extra properties {close: "MELEE", far: "RANGED"}
. One wrinkle is that TypeScript does not automatically generate a type named TwoWayAttackType
corresponding to the property types of the TwoWayAttackType
constant, so if you want one we have to make it manually, as I've done above.
Now you should be able to make your class as desired with no type errors:
class Hero {
attackType: TwoWayAttackType;
constructor() {
this.attackType = TwoWayAttackType['close'];
this.attackType = TwoWayAttackType.MELEE;
this.attackType = TwoWayAttackType[TwoWayAttackType['close']];
}
}
Note that if this method works for you, you can always rename the values/types above so that what I'm calling TwoWayAttackType
is just AttackType
(and then perhaps what I'm calling AttackType
would be something like OneWayAttackType
or BaseAttackType
).
Playground link to code