"Switch" 对 Typescript 中受歧视的联合成员起作用

"Switch" function over discriminated union members in Typescript

假设我在 Typescript 中有一个可区分的联合:

type MyAction = { type: "redirect", url: string} | {type: "returnValue", value: number}

我想编写一个“switch 表达式”,它采用可区分联合的成员和类型到处理器函数的映射,returns 运行 正确处理器函数的结果:

let actionInstance = {type: "returnValue", value: 123} as MyAction
console.log(switchExpression(actionInstance, {
  redirect: (action) => action.url + "(url)",
  returnValue: (action) => `${action.value}` + "(value)"}
))

我希望输入处理器 - 也就是说,如果不是所有类型都在开关中表示,我希望 Typescript 出错,如果我为开关提供的键不是有效类型,或者如果我假设处理器函数中的错误类型(就像我尝试在 redirect 的值中访问 action.value)。

根据 Whosebug 上的其他一些答案,这就是我目前得到的结果 (playground link):

type NarrowAction<T, N> = T extends { type: N } ? T : never;

type StuctureRecord<FunctionReturnType> =
    { [K in MyAction["type"]]: (value: NarrowAction<MyAction, K>) => FunctionReturnType }

function switchExpression<FunctionReturnType>(value: MyAction, cases: StuctureRecord<FunctionReturnType>): FunctionReturnType {
  let f = cases[value.type] as (value: any) => FunctionReturnType
  return f(value)
}

这可以正常工作,但它特定于一个受歧视的联盟 (MyAction)。

有没有一种方法可以使此代码适应 any 受歧视的联盟,其中所有成员都有一个 type 字段,而不是让它只适用于 MyAction?我尝试使用像 DUType extends { type: string } 这样的类型,但这会导致更多错误 (playground link)

注意:我知道我可以直接使用 StructuredRecord 或者我可以只使用常规 switch 语句或带有 assertUnreachable 函数的 if/else 语句(), 但出于学术上的好奇心,我对是否存在上述形式的解决方案很感兴趣,事实上,它是一个符合人体工程学的 util 函数,必须确保类型安全而不必直接键入所有类型注释.

如果您有一个判别联合,其中 type 属性 是判别式,您可以将 StructureRecord 扩展为:

type StuctureRecord<A extends { type: string }, R> =
  { [T in A as T["type"]]: (value: T) => R }

其中 R 对应于您的 FunctionReturnType 类型参数(我喜欢遵循 ​​ 这样您就可以轻松区分类型参数和特定类型),并且 A是被判别联合类型。

除了我使用 key remapping to iterate over the entire discriminated union type A instead of over the type property of A. By doing T in A I'm able to get each member of the union type in term, and just use it as the function parameter type. Otherwise you have to do K in A["type"] and then use K to recreate T from A with something like the Extract<T, U> utility type 之外,这与您的类似,就像您使用 NarrowAction 一样。此更改仅适用于 elegance/convenience,不会真正影响我们关心的任何行为。


此时我们可以将另一个泛型类型参数 A 添加到 switchExpression 对应于被区分的联合:

const switchExpression =
  <A extends { type: string }, R>(value: A, cases: StuctureRecord<A, R>): R => {
    const type: A["type"] = value.type;
    const f = cases[type] as (value: any) => R;
    return f(value);
  }

注意我把type移到它自己的变量中,annotated it as type A["type"]. If you just write value.type the compiler will eagerly resolve that to string because A extends {type: string}. But we don't want that, since cases will be known to have keys in A["type"] but not just string. This saves us from needing another type assertion写成cases[type as A["type"]]

但是因为我们需要 cases[type] as (value: any) => R 在那里,所以没关系。 TypeScript 有一个基本的限制,即编译器无法进行高阶推理以查看 A 联合的每个成员 TStructureRecord<T, R>[T["type"]] 的类型为 [=46] =] 因此调用 cases[value.type](value) 总是安全的。 cases[value.type]valueA 的不同可能缩小范围内的相关性丢失了。有关详细信息,请参阅 microsoft/TypeScript#30581。因此,在 switchExpression 的实现中,您应该根据需要使用尽可能多的类型断言以避免错误,三重检查您的断言是否合理并且您不允许不安全的事情,然后继续。


太好了。不幸的是,当您调用 switchExpression() 时,您会发现类型推断有问题:

switchExpression(actionInstance, {
  redirect: (action) => action.url + "(url)",
  // ------> ~~~~~~ // error, action is implicitly any
  returnValue: (action) => `${action.value}` + "(value)"
  // ---------> ~~~~~~ // error, action is implicitly any    
}).toUpperCase(); // <-- error, object is of type unknown

这里编译器确实将A推断为MyAction,但action的回调参数隐式为any and not the expected members of the MyAction union. This is another limitation of TypeScript. In this case we need the compiler to infer both the type parameters A and R as well as the types of the two callback parameters named action based on context。编译器试图一次完成所有这些,但无法正确获得 action,因为它取决于它还不知道的 A。而R也推断失败,退回到unknown,然后编译器就不让你轻易使用return值了。

GitHub 中有各种问题围绕这个问题;其中之一见 microsoft/TypeScript#25092。每当您同时想要泛型类型推断和上下文回调参数类型推断时,您 运行 就会面临此类问题的风险。

可以通过手动指定AR来结束结:

switchExpression<MyAction, string>(actionInstance, {
  redirect: (action) => action.url + "(url)",
  returnValue: (action) => `${action.value}` + "(value)"
}).toUpperCase(); // okay

但这有点令人难过。


相反,我们可以通过将 switchExpression 变成一个接受 valuecurried 函数来解决这个问题,而 return 是一个接受 cases 的函数。原函数将能够正确地推断出A,之后编译器只需要推断出Raction,因为A已经是一成不变的了:

const switchExpression =
  <A extends { type: string }>(value: A) =>
    <R,>(cases: StuctureRecord<A, R>): R => {
      const type: A["type"] = value.type;
      const f = cases[type] as (value: any) => R;
      return f(value);
    }

这并没有太大的变化,真的。你这样称呼它:

switchExpression(actionInstance)({
  redirect: (action) => action.url + "(url)",
  returnValue: (action) => `${action.value}` + "(value)"
}).toUpperCase(); // okay

如果你眯着眼睛,当你调用 switchExpression 时,你可以认为 )( 就像 ,。但是你可以看到类型推断完全按照我们的意愿发生了。 A 在第一次调用中被推断为 MyAction,因此可用于在第二次调用中根据上下文键入 action,这允许编译器依次推断出 R

就我个人而言,我宁愿使用柯里化并获得良好的类型推断,也不愿使用单个函数调用并且必须手动指定类型,但这取决于您的用例。

Playground link to code