Generic type to get enum keys as union string in 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.

Leave a Comment