Generic type to get enum keys as union string in typescript?

TypescriptGenericsEnums

Typescript Problem Overview


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?

Typescript Solutions


Solution 1 - Typescript

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 - Typescript

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!

Solution 3 - Typescript

This is solution that doesn't require to create new generic types.

If you declare an enum

enum Season { Spring, Summer, Autumn, Winter };

To get to the type you only need to use the keywords keyof typeof

let seasonKey: keyof typeof Season;

Then the variable works as expected

seasonKey = "Autumn"; // is fine
// seasonKey = "AA" <= won't compile

Solution 4 - Typescript

You can just pass a type instead of a value and the compiler won't complain. This you achieve with typeof as you pointed out.
Will be just a bit less automatic:

type AnyEnumKeysAsStrings<TEnumType> = keyof TEnumType;

Which you can use as:

type MyEnumKeysAsStrings = AnyEnumKeysAsStrings<typeof MyEnum>;

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionStephan GView Question on Stackoverflow
Solution 1 - TypescriptjcalzView Answer on Stackoverflow
Solution 2 - TypescriptAkxeView Answer on Stackoverflow
Solution 3 - TypescriptPascal GanayeView Answer on Stackoverflow
Solution 4 - TypescriptNico JonesView Answer on Stackoverflow