从数字中获取 TypeScript 枚举的 keyof typeof

3

我是一个枚举:

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

以及一个类型:

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

我有一个来自API的有效负载,我想将其转换为FirestoreReport类型:

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

我想知道如何将type: 6(如果我通过zod运行负载,只能确定为number类型)成语地映射到keyof typeof ReviewReportType,以便最终获得:

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

我的失败尝试:

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

这给我带来了以下错误:
Type 'string' is not assignable to type "'HARASSMENT" | "INAPPROPRIATE"...'

为什么你需要枚举的键,而不是枚举本身呢? - Matthieu Riegler
@MartinSvoboda 请您知道这些文章是否有帮助 https://catchts.com/safe-enums - captain-yossarian from Ukraine
你遇到了 ms/TS#38806 的问题,因此编译器无法为你推断这样的类型,你需要编写自己的类型函数来实现它。请注意,你需要考虑 undefined 作为可能的结果。这种方法 是否满足你的需求?如果是,我可以撰写一个答案;如果不是,那么我缺少什么? - jcalz
@captain-yossarian来自乌克兰,我认为枚举类型不安全是问题的一部分。 - Martin Svoboda
1
@jcalz,你能写出答案吗?我想要的是一种处理来自API的“数字”不确定性的习惯方法,并确保我成功获取枚举键或者有一段代码分支来防范那些没有列在该枚举中的数字,如果这有意义的话。 - Martin Svoboda
显示剩余2条评论
2个回答

3
目前,数字枚举的反向映射没有强类型化,表示为一个数值 索引签名,其值类型为string。因此,如果您使用number键索引数字枚举,您将得到一个string输出,正如您所注意到的那样。在您传入有效的枚举成员时,这种类型过于广泛。
const str: "HARASSMENT" = ReviewReportType[ReviewReportType.HARASSMENT];
//    ^^^ <-- error, Type 'string' is not assignable to type '"HARASSMENT"'

这是microsoft/TypeScript#38806的主题,目前正在等待更多社区反馈的功能请求。
此外,在传递无效成员的情况下,它的类型范围太窄了,因为它没有预测到可能的undefined(除非您打开--noUncheckedIndexedAccess编译器选项,大多数人不会这样做,并且它不是--strict编译器选项套件的一部分):
const oops = ReviewReportType[123];
// const oops: string (but should really be string | undefined)
oops.toUpperCase(); // no compiler error but RUNTIME ERROR!

如果你想编写一个mapType()函数,以“正确”的方式考虑这两个因素,你可以...但是由于编译器不会为你处理它,所以你需要使用type assertion来告诉编译器ReviewReportType[type]实际上是你声称返回类型的类型。
需要注意的是,我们可以只使用带有类型断言的您的版本,如下所示:
const mapType = (num: number) => ReviewReportType[num] as keyof typeof ReviewReportType;

但它有非常相似的限制...你会得到 typeof ReviewReportType keyof ,而不是 string ,但它仍然太宽泛了。
const str: "HARASSMENT" = mapType(ReviewReportType.HARASSMENT); // still error

"太窄了"
const oops = mapType(123);
// const oops: "HARASSMENT" | "INAPPROPRIATE" | "UNKNOWN_PERSON" | "FAKE_REVIEW" | "OTHER"
oops.toUpperCase(); // still no compiler error but RUNTIME ERROR!

所以你需要小心处理它。
相反,我会写一个通用版本的mapType()函数,尽可能接近准确:
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)

这有点难以理解,但我会尽力解释。该函数是在N中泛型化的,numnumber-constrained类型。返回类型由两部分组成:
  • { [P in keyof typeof ReviewReportType]: typeof ReviewReportType[P] extends N ? P : never }[keyof typeof ReviewReportType] 是一个 分布式对象类型(在 ms/TS#47109 中所称),它立即 索引 到一个映射类型中,以便将类型操作分配到ReviewReportType枚举键的联合上。该操作是检查相应的枚举成员是否可分配给N。如果是,则返回键,否则返回never。因此,如果N6,则当键为"HARASSMENT"时,它将是"HARASSMENT",否则将是never。所有这些的并集就是我们想要的"HARASSMENT"。如果N更宽,比如number,你会得到所有的键(因为每个枚举成员都扩展了number)。

  • (`${N}` extends `${ReviewReportType}` ? never : undefined)部分用于检查N是否可以失败成为枚举成员(我需要使用模板文字类型来做这件事,因为数字枚举被认为比相应的数字文字类型更窄;将两侧转换为string文字可以规避这个问题)。如果可以,则我们希望在输出类型中添加一个undefined...否则不需要。

把这两个结合起来,你就可以得到我能够得到的最接近准确行为。
  const str: "HARASSMENT" = mapType(ReviewReportType.HARASSMENT); // okay

现在这个可行是因为 mapType(ReviewReportType.HARASSMENT) 返回 "HARASSMENT"
  const oops = mapType(123);
  // const oops: undefined
  oops.toUpperCase(); // compiler error now, oops is undefined

这是一个编译错误,因为mapType(123)返回undefined
现在我们可以根据需要使用它:
const report: FirestoreReport = {
  type: mapType(ReviewReportType.HARASSMENT), // okay
  message: "foo",
  reviewId: "bar"
}

这个成功的原因是编译器知道 ReviewReportType.HARASSMENT6,而 mapType(6)"HARASSMENT"。你提到了你正在通过 zod(无论它是什么)来传递信息,所以编译器不会知道这一点。编译器只知道它是一个 数字
function getSomeNumber(): number {
  return Math.floor(Math.random() * 100);
}

"所以你会得到一个错误: "
const report2: FirestoreReport = {
  type: mapType(getSomeNumber()), // error! could be undefined
  message: "",
  reviewId: ""
}

我认为这是正确的行为。如果编译器无法验证您在那里未分配undefined,则应该向您发出警告。您可以通过使用非空断言运算符(!来修复此问题:
const report3: FirestoreReport = {
  type: mapType(getSomeNumber())!, // you *assert* that it's not
  message: "",
  reviewId: ""
}

但是...也许你讨厌那个?
如果是这样的话,这就是我的最终提议。如果返回值将是undefined,则使函数抛出throw,并从返回类型中删除undefined选项:
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];
};

现在你知道,在调用 mapType() 后运行的任何代码都会有枚举的某个键:
const str: "HARASSMENT" = mapType(ReviewReportType.HARASSMENT); // okay
const oops = mapType(123);
// const oops: never;
oops.toUpperCase(); // compiler error now, oops is never

看到oops的类型是never,因为编译器知道控制流永远不会到达那一行。现在这个成功了:
const report2: FirestoreReport = {
  type: mapType(getSomeNumber()), // okay
  message: "",
  reviewId: ""
}
console.log("YAY " + report2.type);

这是很好的;假设 zod 不会给你随机的东西,比如 getSomeNumber(),那么你就没问题了,否则在你离开 mapType() 之前就会收到运行时错误。

代码操场链接


太棒了!非常详细的答案,谢谢!很遗憾,Typescript目前不支持这个:const str: "HARASSMENT" = ReviewReportType[ReviewReportType.HARASSMENT];我最近才了解到https://github.com/colinhacks/zod。由于我正在使用它来验证一个可能不干净的API负载,所以Zod只能给我一个数字事实上。因此,现在我只需要插入您的“mapType”函数来进一步缩小该类型。非常感谢详细的解释。这一切都讲得通! - Martin Svoboda
我添加了一个额外的答案,以便更好地解释我尝试使用API传输的有效负载来利用您的mapType函数所要实现的目标。 - Martin Svoboda

1

在@jcalz的解决方案基础上,如果API传来了我们几乎不知道的有效负载,并且我们想将其映射到特定类型,我们可以利用https://github.com/colinhacks/zod实现以下功能:

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>;

请注意我们调用的 transform 函数,它将 number 缩小到 keyof typeof ReviewReportType

然后我们解析载荷:

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

const parsed = APIPayloadSchema.parse(report)

此时,我们可以安全地构建所需的对象,因为parsed.typeReviewReportType类型的keyof typeof

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接