3

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"...'
Martin Svoboda
  • 297
  • 1
  • 4
  • 18
  • TypeScript allows `const foo: ReviewReportType = ReviewReportType.HARASSMENT | ReviewReportType.INAPPROPRIATE` (bit operations on number enums), so `ReviewReportType[foo]` wouldn't return a `keyof typeof ReviewReportType` – Lauren Yim Aug 11 '22 at 09:12
  • Why do you need the key of the enum in the first place and not the enum itself ? – – Matthieu Riegler Aug 11 '22 at 09:17
  • @MatthieuRiegler Because I want to store `type: HARASSMENT` in Firestore instead of `type: 6`. – Martin Svoboda Aug 11 '22 at 09:35
  • @MartinSvoboda please let me know if any of these articles is helpful https://catchts.com/safe-enums – captain-yossarian from Ukraine Aug 11 '22 at 11:10
  • [Please replace/supplement images of code/errors with plaintext versions.](https://meta.stackoverflow.com/a/285557/2887218) You're running into [ms/TS#38806](https://github.com/microsoft/TypeScript/issues/38806), so the compiler won't infer such a type for you, you'd have to write your own type function to do it. And note that you need to take into account `undefined` as a possible result. Does [this approach](https://tsplay.dev/WyeknW) meet your needs? If so I can write up an answer; if not, what am I missing? – jcalz Aug 11 '22 at 14:41
  • @captain-yossarianfromUkraine I think the enum being unsafe is part of the problem. – Martin Svoboda Aug 11 '22 at 17:10
  • 1
    @jcalz Could you write up the answer, please? What I'm looking for in the answer is an idiomatic way of dealing with the uncertainty of having a `number` coming from an API and making sure that either I successfully fetch the key of the enum or have a branch of code that guards against numbers that are not listed in that enum if that makes sense. Note: I'll remove the screenshot in favour of a code. – Martin Svoboda Aug 11 '22 at 17:12

2 Answers2

3

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • That's awesome! A very detailed answer, thanks! It's a shame that Typescript currently does not support this: `const str: "HARASSMENT" = ReviewReportType[ReviewReportType.HARASSMENT]; ` I've just recently learned about https://github.com/colinhacks/zod. As I'm using it to validate a potentially dirty API payload all Zod can give me is a number as a matter of fact. So right now I just need to plug your `mapType` function to further narrow down that type. Really appreciate the detailed explanation. It all makes sense! – Martin Svoboda Aug 12 '22 at 12:45
  • I've added an additional answer that gives more context to what I was trying to achieve with the payload coming from API leveraging your `mapType` function. – Martin Svoboda Aug 12 '22 at 13:05
1

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,
}
Martin Svoboda
  • 297
  • 1
  • 4
  • 18