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>) {
...
}
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.