Getting keyof typeof TypeScript Enum from number

Viewed 97

I have an enum:

enum ReviewReportType {
  HARASSMENT = 6,
  INAPPROPRIATE = 7,
  UNKNOWN_PERSON = 3,
  FAKE_REVIEW = 8,
  OTHER = 5,
}

and a Type:

export interface FirestoreReport {
  reviewId: string;
  type: keyof typeof ReviewReportType;
  message: string;
}

And I've got a payload coming from an API I want to transform to the FirestoreReport type:

const payload = {
  type: 6,
  message: "foo",
  reviewId: "bar"
}

I'm curious how to idiomatically map the type: 6 (which I can only ascertain to be of type number if I run the payload through zod) into the keyof typeof ReviewReportType so that I end up with:

const report: FirestoreReport = {
  type: "HARASSMENT", 
  message: "foo",
  reviewId: "bar"
}

My failed attempt:

const mapType = (type: number): keyof typeof ReviewReportType => {
  return ReviewReportType[type]
}

This gives me following error:

Type 'string' is not assignable to type "'HARASSMENT" | "INAPPROPRIATE"...'
2 Answers

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

Expanding on @jcalz solution, if there's a payload we know virtually nothing about coming from the API and we want to map that to a certain type we can leverage https://github.com/colinhacks/zod to do the following:

export const APIPayloadSchema = z.object({
  type: z
    .number()
    .refine((type) => Object.values(ReviewReportType).includes(type), {
      message: 'type has to be an allowed number',
    })
    .transform((val) => mapType(val)),
  message: z.string(),
  reviewId: z.string(),
});

export type APIPayload = z.infer<typeof APIPayloadSchema>;

Notice the transform where we call the function that narrows down the number to keyof typeof ReviewReportType

We then parse the payload:

const payload = {
  type: Math.floor(Math.random() * 100),
  message: "Foo",
  reviewId: "Bar",
}

const parsed = APIPayloadSchema.parse(report)

And at this point we can safely build the desired object because parsed.type is a keyof typeof ReviewReportType:

const report: FirestoreReport = {
  type: parsed.type, 
  message: parsed.message,
  reviewId: parsed.reviewId,
}
Related