typescript 将值Map到兼容函数会创建不可调用的函数联合

hsgswve4  于 2023-03-09  发布在  TypeScript
关注(0)|答案(1)|浏览(93)

我不得不处理从许多可能的值中获取一个值的问题,但是在这些值中有一个id可以用来获取正确的change函数,但是对于typescript,这不起作用,因为所有函数的并集是不可调用的。

type Module<T> = {
  change: (value: T) => void
}
enum ModuleID {
  A = "id:A",
  B = "id:B"
}
interface Identifiable<ID extends ModuleID> {
  id: ID
}
// Module A
interface A extends Identifiable<ModuleID.A> {
  item: 'value:A'
}
export const moduleA: Module<A> = {
  change(a: A) {}
}
// Module B
interface B extends Identifiable<ModuleID.B> {
  item: 'value:B'
}
export const moduleB: Module<B> = {
  change(a: B) {}
}
// Map id to module containing the correct `change` function
const moduleMap = {
  [ModuleID.A]: moduleA,
  [ModuleID.B]: moduleB
}
// While conceptually correct, this does not work with typescript
function changeValue(value: A | B) {
  const module = moduleMap[value.id]
  // The intersection 'A & B' was reduced to 'never' because 
  // property 'item' has conflicting types in some constituents.
  module.change(value)
}

我该如何重新构造它,以便可以基于值的id以类型安全的方式调用适当的change方法。

zwghvu4y

zwghvu4y1#

这个问题我称之为 * correlated unions *,如microsoft/TypeScript#30581中所述。在changeValue()的主体中,module.change的类型和value的类型都是联合类型,但是编译器无法对它们之间的 * correlation * 进行建模。如果编译器能够将value先缩小到A,然后缩小到B,它将很高兴:

function changeValue(value: A | B) {
  if (value.id === ModuleID.A) {
    const module = moduleMap[value.id]
    module.change(value) // okay
  } else {
    const module = moduleMap[value.id]
    module.change(value) // okay
  }
}

但是它只能通过不同的控制流来实现,这不是你想要的,因为它是冗余的,并且与输入联合体一起冗余地扩展,它不能查看一行代码,并同时为每一个收缩计算它。
microsoft/TypeScript#47109中介绍了处理相关联合的推荐解决方案,它涉及到重构类型,以便使用generics而不是联合,并且泛型操作是根据mapped typesindexes into这类类型显式编写的。目标是changeValue()在某些类似键的类型K中应该是泛型的。使得对于某些Fvalue被视为F<K>类型,而对于相同的Fmodule.change被视为(value: F<K>) => void类型。
这里有一种方法,首先让我们创建一个类型,在这个类型中我们可以根据ModuleID查找change()的参数类型:

type Modules = A | B;
type ChangeArg = { [M in Modules as M['id']]: M }
/* type ChangeArg = {
  "id:A": A;
  "id:B": B;
} */

然后我们将moduleMap的类型显式地写为Map类型:

const moduleMap: { [K in ModuleID]: Module<ChangeArg[K]> } = {
  [ModuleID.A]: moduleA,
  [ModuleID.B]: moduleB
}

最后,我们将使changeValueK中泛型化,即所涉及的ModuleID的类型:

function changeValue<K extends ModuleID>(value: Identifiable<K> & ChangeArg[K]) {
  const _value: Identifiable<K> = value;
  const module = moduleMap[_value.id]
  module.change(value)
}

那需要一些解释为了使其工作,编译器需要理解valueIdentifable<K>,从而value.idK类型,并且还需要理解valueChangeArg[K]类型,从而将其视为moduleMap[value.id]的有效输入。因此我通过将value的类型定义为这两种类型的交集来帮助编译器(即使对于每个KChangeArg[K]已经是Identifiable<K>的子类型),然后在获取id属性之前扩展到Identifiable<K>(因此编译器将其保持为K,而不会扩展到更不可用的内容)。
您可以验证module.change现在被视为(value: ChangeArg[K]) => void类型,它接受Identifiable<K> & ChangeArg[K]类型的参数,因此changeValue()的主体编译没有错误。
我们还要确保来电者仍然有合理的行为:

changeValue({ id: ModuleID.A, item: 'value:A' }); // okay
changeValue({ id: ModuleID.A, item: 'value:B' }); // error
changeValue({ id: ModuleID.B, item: 'value:B' }); // okay
changeValue({ id: ModuleID.B, item: 'value:A' }); // error

看起来不错!
最后,这种重构方式可能不值得你花费时间/精力/复杂度,你完全可以保持代码原样,只使用类型Assert来抑制警告,只要你确信它是正确的:

const module = moduleMap[value.id] // Module<A> | Module<B>
module.change(value as never); // I know this line is fine so leave me alone

采用哪种方式取决于哪个对您的用例更重要:如果你想要编译器验证的类型安全,并且不关心复杂性,那么重构可能是一条可行之路;如果你想要编译器验证的类型安全,但又不想太复杂,你可能想通过冗余的控制流来进行收缩。如果你不太关心编译器验证的类型安全,而不想向前发展,那么Assert可能是你的解决方案。这取决于你。
Playground代码链接

相关问题