typescript 数组类型索引签名在使用“number”索引时失去了特异性

z8dt9xmd  于 2023-08-08  发布在  TypeScript
关注(0)|答案(2)|浏览(170)

我最近遇到了下面的TypeScript代码。

type API = {
    method1: Array<(num: number) => void>,
    method2: Array<(str: string) => void>
}

const methods: API = {
    method1: [],
    method2: []
}

function add<K extends keyof API>(key: K, method: API[K][number]) {
    methods[key].push(method);
}

字符串
add函数中的索引签名API[K]正确键入。然而,一旦你在末尾添加[number]来获取数组的底层类型,TypeScript就会忘记索引签名,而是创建一个API类型中出现的两个函数类型的联合。
这是我在TypeScript playground中得到的错误。

Argument of type '((num: number) => void) | ((str: string) => void)' is not assignable to parameter of type '((num: number) => void) & ((str: string) => void)'.
  Type '(num: number) => void' is not assignable to type '((num: number) => void) & ((str: string) => void)'.
    Type '(num: number) => void' is not assignable to type '(str: string) => void'.
      Types of parameters 'num' and 'str' are incompatible.
        Type 'string' is not assignable to type 'number'.


当然,简单的修复方法是从类型API中删除数组类型,并为methods对象创建一个Map类型。

type API = {
    method1: (num: number) => void,
    method2: (str: string) => void
}

const methods: { [K in keyof API]: API[K][] } = {
    method1: [],
    method2: []
}

function add<K extends keyof API>(key: K, method: API[K]) {
    methods[key].push(method);
}


我想知道为什么第一个例子不起作用。TypeScript是否有一些特定的东西不允许使用索引类型?任何关于这一点的见解都非常感谢。

332nm8kg

332nm8kg1#

为了便于讨论,我将您的API类型重命名为

type APIArray = {
    method1: Array<(num: number) => void>,
    method2: Array<(str: string) => void>
}

字符串

type APIElement = {
    method1: (num: number) => void,
    method2: (str: string) => void
}


这样就不会混淆我们说的是哪个API了。
TypeScript不能对generic类型执行任意分析。有几个操作它可以正确处理,否则它只是通过将泛型类型参数扩展到其约束来走捷径。
你第一种方法的问题是

function add<K extends keyof APIArray>(key: K, method: APIArray[K][number]) {
    methods[key].push(method); // error
}


methods[key]APIArray[K]的类型不被视为单个数组类型。当调用push()方法时,它最终将K扩展到其约束,即union keyof APIArray,因此method[key].push扩展到以下函数的union:

const m = methods[key].push;
// const m: 
//   ((...items: ((num: number) => void)[]) => number) | 
//   ((...items: ((str: string) => void)[]) => number)
methods[key].push(method); // error


函数的联合只能用联合成员的参数的 * 交集 * 来安全地调用(参见TS3.3发行说明描述了对调用函数联合的支持),因此methods[key].push唯一接受的参数是 * a (num: number) => void * 和 * a (str: string) => void。当然,method只是其中之一,它失败了。
编译器已丢失methods[key].pushmethod类型之间的 * 相关性 * 的跟踪。根据其分析,它们只是不相关的工会类型。因此,它担心methods[key]可能是一个string-接受方法的数组,而method可能是一个number-接受方法,反之亦然。我们知道这不太可能,但编译器看不到它。
(注意我说的是“不太可能”,而不是“不可能”。从技术上讲,这是一个有效的调用:

// not likely
add(Math.random() < 0.999 ? "method1" : "method2", (s: string) => s.toUpperCase())


我不打算让这个更长的探索为什么这是接受和在什么情况下可以防止这一点。TS类型系统并不完全健全,有时会出现这样的情况,但这与为什么两个代码版本被不同对待没有直接关系。
microsoft/TypeScript#30581中描述了对“相关联合”的普遍缺乏支持,当编译器无法正确分析联合约束的泛型时,也会发生同样的问题。
另一方面,你的第二种方法

const methods: { [K in keyof APIElement]: APIElement[K][] } = {
    method1: [],
    method2: []
}

function add<K extends keyof APIElement>(key: K, method: APIElement[K]) {
    methods[key].push(method);
}


之所以有效,是因为methods[key].push的类型被视为接受APIElement[K]类型参数的单个函数,而APIElement[K]类型正是method的类型:

const m = methods[key].push
// const m: (...items: APIElement[K][]) => number


它不是一个union,因为编译器更好地支持将泛型indexed accesses直接转换为mapped types。这种支持在microsoft/TypeScript#47109中被添加/加强,特别是作为一种帮助处理相关联合的方法。
通过将methods的类型显式地写为APIElement上的Map类型,你已经给了编译器一个提示,即它使用像key这样的泛型索引索引将导致一个 * 单一的泛型 *,而不是一个 * 特定 * 的联合。
所以你去。
这种差异并不明显,坦率地说,每当人们遇到这种情况时,都需要大量的解释和指向ms/TS#30581和ms/TS#47109。通常情况下,人们遇到这种情况是因为无法编写有效的版本,这是一个你已经设法克服的绊脚石。但无论如何,问题都是一样的。
Playground代码链接

ecbunoof

ecbunoof2#

在第二个版本中,push方法期望API[K],这是参数method的确切类型,因此它不必尝试解析任何泛型来查看它是否有效。
在第一个版本中,由于存在第二层间接寻址,typescript试图解析泛型,并确定push需要接受一个与两个函数签名兼容的参数,但方法只能是其中之一。(显示用基本类型替换的通用类型并显示交集和并集的Playground链接)
我相信这主要是typescript的局限性,即使明确地说你使用push方法的相同参数也会产生相同的错误:Playground

function add<K extends keyof typeof methods>(key: K, ...method: Parameters<typeof methods[K]["push"]>) {
    methods[key].push(...method);
// gives same error here ^
}

字符串
我真的没有比这更好的信息了,希望其他对开发历史有更多了解的人可以插话,但我想我有足够的话要说,这可能有助于发布答案:)

相关问题