Currently, the reverse mapping for numeric enums is not strongly typed and is represented as a numeric index signature whose value type is string
. So if you index into a numeric enum with a number
key, you will get a string
output, as you noticed. This is too widely typed in the case where you pass in a valid enum member:
const str: "HARASSMENT" = ReviewReportType[ReviewReportType.HARASSMENT];
// ^^^ <-- error, Type 'string' is not assignable to type '"HARASSMENT"'
This is the subject of microsoft/TypeScript#38806, a feature request currently waiting for more community feedback.
And it's also too narrowly typed in the case where you pass in an invalid member, since it doesn't anticipate a possible undefined
(unless you turn on the --noUncheckedIndexedAccess
compiler option which most people don't do and isn't part of the --strict
suite of compiler options):
const oops = ReviewReportType[123];
// const oops: string (but should really be string | undefined)
oops.toUpperCase(); // no compiler error but RUNTIME ERROR!
If you want to write a mapType()
function that takes account of both of these in the "right" way, you can... but because the compiler doesn't handle it for you you'll need to use a type assertion to tell the compiler that ReviewReportType[type]
is actually of the type you claim to return.
Note that we could just use your version with a type assertion, like this:
const mapType = (num: number) => ReviewReportType[num] as keyof typeof ReviewReportType;
but it has very similar limitations... you get keyof typeof ReviewReportType
instead of string
, but it's still too wide
const str: "HARASSMENT" = mapType(ReviewReportType.HARASSMENT); // still error
and too narrow
const oops = mapType(123);
// const oops: "HARASSMENT" | "INAPPROPRIATE" | "UNKNOWN_PERSON" | "FAKE_REVIEW" | "OTHER"
oops.toUpperCase(); // still no compiler error but RUNTIME ERROR!
so you would need to be careful with it.
Instead I'll write a generic version of mapType()
that is as close to accurate as I can make it:
const mapType = <N extends number>(num: N) => ReviewReportType[num] as
{ [P in keyof typeof ReviewReportType]: typeof ReviewReportType[P] extends N ? P : never }[
keyof typeof ReviewReportType] | (`${N}` extends `${ReviewReportType}` ? never : undefined)
That's quite a mouthful, but I'll try to explain it. The function is generic in N
the number
-constrained type of num
. The return type is in two parts:
The { [P in keyof typeof ReviewReportType]: typeof ReviewReportType[P] extends N ? P : never }[keyof typeof ReviewReportType]
is a distributive object type (as coined in ms/TS#47109), where we immediately index into a mapped type in order to distribute a type operation over the union of keys in the ReviewReportType
enum. That operation is to check if the corresponding enum member is assignable to N
. If so we return the key, otherwise we return never
. So if N
is 6
, then this will be "HARASSMENT"
when the key is "HARASSMENT"
, and never
otherwise... the union of all of those is just "HARASSMENT"
, which is what we want. If N
is wider, like number
, you get all the keys (since each enum member extends number`).
The (`${N}` extends `${ReviewReportType}` ? never : undefined)
part checks to see if N
can fail to be an enum member (I need to use template literal types to do this because numeric enums are considered narrower than the equivalent numeric literal types; converting both sides to a string
literal circumvents this). If it can, then we want to add an undefined
to the output type... otherwise we don't.
Put those two together and you get the closest I can get to accurate behavior:
const str: "HARASSMENT" = mapType(ReviewReportType.HARASSMENT); // okay
That works now because mapType(ReviewReportType.HARASSMENT)
returns "HARASSMENT"
.
const oops = mapType(123);
// const oops: undefined
oops.toUpperCase(); // compiler error now, oops is undefined
That is now a compiler error because mapType(123)
returns undefined
.
Now we can use this as desired:
const report: FirestoreReport = {
type: mapType(ReviewReportType.HARASSMENT), // okay
message: "foo",
reviewId: "bar"
}
This succeeds because the compiler knows ReviewReportType.HARASSMENT
is 6
and that mapType(6)
is "HARASSMENT"
. You mentioned that you're passing stuff through zod
(whatever that is ) so the compiler will not know this. The compiler just knows it's some number
:
function getSomeNumber(): number {
return Math.floor(Math.random() * 100);
}
And so you'll get an error:
const report2: FirestoreReport = {
type: mapType(getSomeNumber()), // error! could be undefined
message: "",
reviewId: ""
}
I contend that's the right behavior. The compiler should warn you if it can't verify that you're not assigning undefined
there. You can fix that by using the non-null assertion operator (!
):
const report3: FirestoreReport = {
type: mapType(getSomeNumber())!, // you *assert* that it's not
message: "",
reviewId: ""
}
But... maybe you hate that?
If so then here's my final proposal. Make the function throw
if the return value is going to be undefined
, and remove the undefined
possibility from the return type:
const mapType = <N extends number>(num: N) => {
const ret = ReviewReportType[num];
if (typeof ret === "undefined") throw new Error("YOU GAVE ME " + num + " NOOOOOOOO!!!!! ");
return ret as {
[P in keyof typeof ReviewReportType]: typeof ReviewReportType[P] extends N ? P : never
}[keyof typeof ReviewReportType];
};
Now you know that any code running after a call to mapType()
will have some key of the enum:
const str: "HARASSMENT" = mapType(ReviewReportType.HARASSMENT); // okay
const oops = mapType(123);
// const oops: never;
oops.toUpperCase(); // compiler error now, oops is never
See that oops
is of type never
because the compiler knows control flow will never make it to that line. And now this succeeds:
const report2: FirestoreReport = {
type: mapType(getSomeNumber()), // okay
message: "",
reviewId: ""
}
console.log("YAY " + report2.type);
which is great; assuming zod
never gives you random things like getSomeNumber()
you'll be fine, and otherwise you'll get a runtime error before you make it out of mapType()
.
Playground link to code