Generic type to get enum keys as union string in typescript?
Consider the following typescript enum:
enum MyEnum { A, B, C };
If I want another type that is the unioned strings of the keys of that enum, I can do the following:
type MyEnumKeysAsStrings = keyof typeof MyEnum; // "A" | "B" | "C"
This is very useful.
Now I want to create a generic type that operates universally on enums in this way, so that I can instead say:
type MyEnumKeysAsStrings = AnyEnumKeysAsStrings<MyEnum>;
I imagine the correct syntax for that would be:
type AnyEnumKeysAsStrings<TEnum> = keyof typeof TEnum; // TS Error: 'TEnum' only refers to a type, but is being used as a value here.
But that generates a compile error: "'TEnum' only refers to a type, but is being used as a value here."
This is unexpected and sad. I can incompletely work around it the following way by dropping the typeof from the right side of the declaration of the generic, and adding it to the type parameter in the declaration of the specific type:
type AnyEnumAsUntypedKeys<TEnum> = keyof TEnum;
type MyEnumKeysAsStrings = AnyEnumAsUntypedKeys<typeof MyEnum>; // works, but not kind to consumer. Ick.
I don't like this workaround though, because it means the consumer has to remember to do this icky specifying of typeof on the generic.
Is there any syntax that will allow me to specify the generic type as I initially want, to be kind to the consumer?
Solution 1:
No, the consumer will need to use typeof MyEnum
to refer to the object whose keys are A
, B
, and C
.
LONG EXPLANATION AHEAD, SOME OF WHICH YOU PROBABLY ALREADY KNOW
As you are likely aware, TypeScript adds a static type system to JavaScript, and that type system gets erased when the code is transpiled. The syntax of TypeScript is such that some expressions and statements refer to values that exist at runtime, while other expressions and statements refer to types that exist only at design/compile time. Values have types, but they are not types themselves. Importantly, there are some places in the code where the compiler will expect a value and interpret the expression it finds as a value if possible, and other places where the compiler will expect a type and interpret the expression it finds as a type if possible.
The compiler does not care or get confused if it is possible for an expression to be interpreted as both a value and a type. It is perfectly happy, for instance, with the two flavors of null
in the following code:
let maybeString: string | null = null;
The first instance of null
is a type and the second is a value. It also has no problem with
let Foo = {a: 0};
type Foo = {b: string};
where the first Foo
is a named value and the second Foo
is a named type. Note that the type of the value Foo
is {a: number}
, while the type Foo
is {b: string}
. They are not the same.
Even the typeof
operator leads a double life. The expression typeof x
always expects x
to be a value, but typeof x
itself could be a value or type depending on the context:
let bar = {a: 0};
let TypeofBar = typeof bar; // the value "object"
type TypeofBar = typeof bar; // the type {a: number}
The line let TypeofBar = typeof bar;
will make it through to the JavaScript, and it will use the JavaScript typeof operator at runtime and produce a string. But type TypeofBar = typeof bar
; is erased, and it is using the TypeScript type query operator to examine the static type that TypeScript has assigned to the value named bar
.
Now, most language constructs in TypeScript that introduce names create either a named value or a named type. Here are some introductions of named values:
const value1 = 1;
let value2 = 2;
var value3 = 3;
function value4() {}
And here are some introductions of named types:
interface Type1 {}
type Type2 = string;
But there are a few declarations which create both a named value and a named type, and, like Foo
above, the type of the named value is not the named type. The big ones are class
and enum
:
class Class { public prop = 0; }
enum Enum { A, B }
Here, the type Class
is the type of an instance of Class
, while the value Class
is the constructor object. And typeof Class
is not Class
:
const instance = new Class(); // value instance has type (Class)
// type (Class) is essentially the same as {prop: number};
const ctor = Class; // value ctor has type (typeof Class)
// type (typeof Class) is essentially the same as new() => Class;
And, the type Enum
is the type of an element of the enumeration; a union of the types of each element. While the value Enum
is an object whose keys are A
and B
, and whose properties are the elements of the enumeration. And typeof Enum
is not Enum
:
const element = Math.random() < 0.5 ? Enum.A : Enum.B;
// value element has type (Enum)
// type (Enum) is essentially the same as Enum.A | Enum.B
// which is a subtype of (0 | 1)
const enumObject = Enum;
// value enumObject has type (typeof Enum)
// type (typeof Enum) is essentially the same as {A: Enum.A; B: Enum.B}
// which is a subtype of {A:0, B:1}
Backing way way up to your question now. You want to invent a type operator that works like this:
type KeysOfEnum = EnumKeysAsStrings<Enum>; // "A" | "B"
where you put the type Enum
in, and get the keys of the object Enum
out. But as you see above, the type Enum
is not the same as the object Enum
. And unfortunately the type doesn't know anything about the value. It is sort of like saying this:
type KeysOfEnum = EnumKeysAsString<0 | 1>; // "A" | "B"
Clearly if you write it like that, you'd see that there's nothing you could do to the type 0 | 1
which would produce the type "A" | "B"
. To make it work, you'd need to pass it a type that knows about the mapping. And that type is typeof Enum
...
type KeysOfEnum = EnumKeysAsStrings<typeof Enum>;
which is like
type KeysOfEnum = EnumKeysAsString<{A:0, B:1}>; // "A" | "B"
which is possible... if type EnumKeysAsString<T> = keyof T
.
So you are stuck making the consumer specify typeof Enum
. Are there workarounds? Well, you could maybe use something that does that a value, such as a function?
function enumKeysAsString<TEnum>(theEnum: TEnum): keyof TEnum {
// eliminate numeric keys
const keys = Object.keys(theEnum).filter(x =>
(+x)+"" !== x) as (keyof TEnum)[];
// return some random key
return keys[Math.floor(Math.random()*keys.length)];
}
Then you can call
const someKey = enumKeysAsString(Enum);
and the type of someKey
will be "A" | "B"
. Yeah but then to use it as type you'd have to query it:
type KeysOfEnum = typeof someKey;
which forces you to use typeof
again and is even more verbose than your solution, especially since you can't do this:
type KeysOfEnum = typeof enumKeysAsString(Enum); // error
Blegh. Sorry.
TO RECAP:
- THIS IS NOT POSSIBLE;
- TYPES AND VALUES BLAH BLAH;
- STILL NOT POSSIBLE;
- SORRY.
Hope that makes some sense. Good luck.
Solution 2:
It actually is possible.
enum MyEnum { A, B, C };
type ObjectWithValuesOfEnumAsKeys = { [key in MyEnum]: string };
const a: ObjectWithValuesOfEnumAsKeys = {
"0": "Hello",
"1": "world",
"2": "!",
};
const b: ObjectWithValuesOfEnumAsKeys = {
[MyEnum.A]: "Hello",
[MyEnum.B]: "world",
[MyEnum.C]: "!",
};
// Property '2' is missing in type '{ 0: string; 1: string; }' but required in type 'ObjectWithValuesOfEnumAsKeys'.
const c: ObjectWithValuesOfEnumAsKeys = { // Invalid! - Error here!
[MyEnum.A]: "Hello",
[MyEnum.B]: "world",
};
// Object literal may only specify known properties, and '6' does not exist in type 'ObjectWithValuesOfEnumAsKeys'.
const d: ObjectWithValuesOfEnumAsKeys = {
[MyEnum.A]: "Hello",
[MyEnum.B]: "world",
[MyEnum.C]: "!",
6: "!", // Invalid! - Error here!
};
Playground Link
EDIT: Lifted limitation!
enum MyEnum { A, B, C };
type enumValues = keyof typeof MyEnum;
type ObjectWithKeysOfEnumAsKeys = { [key in enumValues]: string };
const a: ObjectWithKeysOfEnumAsKeys = {
A: "Hello",
B: "world",
C: "!",
};
// Property 'C' is missing in type '{ 0: string; 1: string; }' but required in type 'ObjectWithValuesOfEnumAsKeys'.
const c: ObjectWithKeysOfEnumAsKeys = { // Invalid! - Error here!
A: "Hello",
B: "world",
};
// Object literal may only specify known properties, and '6' does not exist in type 'ObjectWithValuesOfEnumAsKeys'.
const d: ObjectWithKeysOfEnumAsKeys = {
A: "Hello",
B: "world",
C: "!",
D: "!", // Invalid! - Error here!
};
Playground Link
- This work with
const enum
too!