typescript

Discriminatable enum


I need to be able to determine whether a value passed as any is an enum. This seems to be impossible with the enum type in Typescript, so ok, I guess I need to make a wrapper type. Ideally, I want to set up something I can use in a type definition. The question is what’s the most ergonomic/idiomatic way to do this? It would be nice to be able to do something like

type Foo = MyEnum(FOO, BAR)

and get something that behaves like

enum {FOO="FOO", BAR="BAR"}

except I can tell that it’s a MyEnum, but I’m guessing this isn’t exactly possible.

The objective is to be able to do identify the type at runtime, so I can have

if (value instanceof Map) {
  ...
}
else if (value instanceof Array) {
  ...
}
else if (<something to identify an enum>) {
  ...
}

Solution

  • Since TypeScript enum values are just strings (or numbers) at runtime, there's no way to tell the difference between MyEnum.FOO and "FOO" (assuming for this question that you always want the key and the value of an enum to be the same). So yes, you need to replace an enum value with some kind of enum-value-like wrapper object that contains more information. There are many possible approaches, but here's one I'll call a TaggedEnum:

    type TaggedEnum<N, K extends string> = { [P in K]: {
       tag: N,
       value: P
    } }[K]
    

    Here a TaggedEnum<N, K> is an object with a tag property corresponding to the name of the enum type, and a value property corresponding to the value of the enum type. The above is a distributive object type (as coined in microsoft/TypeScript#47109 which distributes across unions in K, so that TaggedEnum<"MyEnum", "FOO" | "BAR"> is equivalent to TaggedEnum<"MyEnum", "FOO"> | TaggedEnum<"MyEnum", "BAR">.

    Of course that's just the enum values, you also need the object that holds these enum values at the corresponding keys:

    function makeTaggedEnum<N extends string, K extends string>(
       name: N, ...enums: K[]): { [P in K]: TaggedEnum<N, P> } {
       return Object.fromEntries(enums.map(e => [e, { tag: name, value: e }])) as any
    }
    

    This uses Object.fromEntries() to create an object whose keys are the same as the enum keys in enums, and whose values are TaggedEnum objects:

    const MyEnum = makeTaggedEnum("MyEnum", "FOO", "BAR");
    console.log(MyEnum);
    /*  {
      "FOO": {
        "tag": "MyEnum",
        "value": "FOO"
      },
      "BAR": {
        "tag": "MyEnum",
        "value": "BAR"
      }
    }  */
    

    And if you need a type corresponding to MyEnum, you could either define it directly with TaggedEnum:

    type MyEnum = TaggedEnum<"MyEnum", "FOO" | "BAR">`
    

    or in terms of the type of the MyEnum object:

    type MyEnum = typeof MyEnum[keyof typeof MyEnum];
    

    Either way it's equivalent to

    /* type MyEnum = {
        tag: "MyEnum";
        value: "FOO";
    } | {
        tag: "MyEnum";
        value: "BAR";
    } */
    

    Now if you want to determine if something is a TaggedEnum, you can check for the tag and value properties:

    function isTaggedEnum<N extends string>(
       value: any, name?: N
    ): value is TaggedEnum<N, string> {
       return value && typeof value === "object" && "tag" in value &&
          (name === undefined || value.tag === name) &&
          "value" in value && typeof value.value === "string";
    }
    

    and note that isTaggedEnum() returns a type predicate to narrow the input to the appropriate tagged enum type. If you call isTaggedEnum(value) without a name property, then value can only be narrowed to TaggedEnum<string, string>. Otherwise, if you call isTaggedEnum(value, name), you can narrow value to TaggedEnum<typeof name, string>.


    For real enums you would just check the value with ===, but since you have a wrapper object you need to drill into each one. So instead of if (value === MyEnum.FOO) you have to write if (matchesTaggedEnum(MyEnum.FOO, value)):

    function matchesTaggedEnum<N extends string, K extends string>(
       reference: TaggedEnum<N, K>, test: TaggedEnum<N, string>): test is TaggedEnum<N, K> {
       return test.value === reference.value
    }
    

    This also returns a type predicate to narrow the test input.


    Armed with these, let's see how you can take an arbitrary runtime value of type any and determine what it is:

    function doAThing(x: any) {
       if (!isTaggedEnum(x)) return "not a tagged enum";
       if (!isTaggedEnum(x, "MyEnum")) return "not my enum";
       if (!matchesTaggedEnum(x, MyEnum.BAR)) return "not BAR";
       return "is BAR";
    }
    
    console.log(doAThing(123)); // not a tagged enum
    const SomeOtherEnum = makeTaggedEnum("SomeOtherEnum", "BAR", "BAZ", "QUX");
    console.log(doAThing(SomeOtherEnum.BAR)) // not my enum
    console.log(doAThing(MyEnum.FOO)); // not BAR
    console.log(doAThing(MyEnum.BAR)); // is BAR
    

    Looks good. You can tell if something is a tagged enum or not, if it's from a particular tagged enum, and if its value is the same as another tagged enum of the same tag.

    Playground link to code