Array类型的索引签名在使用`number`索引时失去了特定性。

huangapple go评论65阅读模式
英文:

Array type index signature loses specificity when indexed with `number`

问题

以下是您提供的TypeScript代码的翻译:

最近我遇到了以下的TypeScript代码

类型API定义如下
```ts
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中得到的错误 链接在这里

类型“((num: number) => void) | ((str: string) => void)”的参数无法分配给类型“((num: number) => void) & ((str: string) => void)”的参数。
  类型“(num: number) => void”无法分配给类型“((num: number) => void) & ((str: string) => void)”。
    类型“(num: number) => void”无法分配给类型“(str: string) => void”。
      参数的类型“num”和“str”不兼容。
        类型“string”无法分配给类型“number”。

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

类型API更新如下
```ts
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规则禁止这种对索引类型的使用?对此的任何见解都将不胜感激。

英文:

I recently came across the following TypeScript code.

type API = {
    method1: Array&lt;(num: number) =&gt; void&gt;,
    method2: Array&lt;(str: string) =&gt; void&gt;
}

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

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

The index signature API[K] in the add function types correctly. However, once you add the [number] at the end to grab the array's underlying type, TypeScript forgets the index signature and instead creates a union of both the function types that appear in type API.

This is the error I get in the TypeScript playground here .

Argument of type &#39;((num: number) =&gt; void) | ((str: string) =&gt; void)&#39; is not assignable to parameter of type &#39;((num: number) =&gt; void) &amp; ((str: string) =&gt; void)&#39;.
  Type &#39;(num: number) =&gt; void&#39; is not assignable to type &#39;((num: number) =&gt; void) &amp; ((str: string) =&gt; void)&#39;.
    Type &#39;(num: number) =&gt; void&#39; is not assignable to type &#39;(str: string) =&gt; void&#39;.
      Types of parameters &#39;num&#39; and &#39;str&#39; are incompatible.
        Type &#39;string&#39; is not assignable to type &#39;number&#39;.

Of course, the easy fix is to remove the array types from type API and created a mapped type for the methods object.

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

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

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

I'm hoping to understand why the first example doesn't work. Is there something specific about TypeScript that disallows such use of index types? Any insight on this is much appreciated.

答案1

得分: 2

以下是您要翻译的内容:

For ease of discussion I'm going to rename your API types to

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

and

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

so that there's no confusion which API we're talking about.


TypeScript isn't really able to perform arbitrary analysis over generic types. There are a few operations it can deal with properly, and otherwise it just takes shortcuts by widening generic type parameters to their constraints.


The problem with your first approach

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

is that the type of methods[key], APIArray[K], is not seen as a single array type. When you call the push() method, it ends up widening K to its constraint, the union keyof APIArray, and therefore method[key].push is widened to the following union of functions:

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

A union of functions can only be safely called with an intersection of the union members' arguments (see the TS3.3 release notes describing support for calling unions of functions), and so the only thing methods[key].push will accept as an argument is something which is both a (num: number) => void and a (str: string) => void. But of course method is only going to be one of those, and it fails.

The compiler has lost track of the correlation between the type of methods[key].push and method. They're just uncorrelated union types, according to its analysis. Hence it is worried that maybe methods[key] will be an array of string-accepting methods while method will be a number-accepting method, and vice versa. We know this is unlikely, but the compiler can't see it.

(Notice I said unlikely and not impossible. This is technically a valid call:

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

I'm not going to make this even longer by exploring why that is accepted and in which circumstances this can be prevented. The TS type system isn't fully sound and that things like this sometimes crop up, but it really isn't directly relevant to why your two code versions are treated differently.)

The general lack of support for "correlated unions" is described in microsoft/TypeScript#30581, and the same problem happens when you have union-constrained generics that the compiler can't analyze properly.


On the other hand, your second approach

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

works because the type of methods[key].push is seen as a single function that accepts an argument of type APIElement[K], which is exactly the type of method:

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

It's not a union because the compiler has better support for direct generic indexed accesses into mapped types. This support was added/strengthened in microsoft/TypeScript#47109 specifically as a way to help deal with correlated unions.

By writing the type of methods explicitly as a mapped type over APIElement, you have given the compiler a hint that it indexing into it with a generic index like key will result in a single generic thing, instead of a union of specific things.

So there you go.


It's not obvious that this difference should exist, and frankly it takes a lot of explaining and pointing to ms/TS#30581 and ms/TS#47109 whenever people run into this situation. Usually people hit this by being unable to write the version that works, a stumbling block which you've managed to overcome. But the issues are the same either way.

[Playground link to code](https://www.typescriptlang.org/play?ts=5.1.6#code/HYQwtgpgzgDiDGEAEAFATgewEYBsJiQG8AoJMpAFwE8ZkBBFASTrTRCqQF4jTy-IKACwwATAIwAuJCzZUAPAApgAVzBSVYLBDQBKLgD4kANwwBLEfoA0vPmQHCRAJikz2iqBTRSPaU8ADmepyGJub6NkgAvsQR8BjAHkj2olAuTK4c3CS25MniUgDaALrWOXYQQqLOSMUR0REA9A1IwBgUSDimANYQOFQRICIiCgCyIEIAdGzAIhhgCnpySAAMEwCcG0gA-EgARHliu0hS+xUOjruWSAqpSD5+gQZ3ExQYAKowtGgAwiBQEAsdDE+AAzZTAeAUUzxJCDERyADSSAgAA8KBAZlAkD0qBgQdJ0qx2PoFDipAirnk0swiVQCgiigUNFo0EU9NkynEEu0CNw8lACjiihMYMooIIANwRPhNJBcxJqJDS8iysgKBQTTWmdFgW7qjTqVQsoIhMwiHTFE0tI3aPQAHyV

英文:

For ease of discussion I'm going to rename your API types to

type APIArray = {
    method1: Array&lt;(num: number) =&gt; void&gt;,
    method2: Array&lt;(str: string) =&gt; void&gt;
}

and

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

so that there's no confusion which API we're talking about.


TypeScript isn't really able to perform arbitrary analysis over generic types. There are a few operations it can deal with properly, and otherwise it just takes shortcuts by widening generic type parameters to their constraints.


The problem with your first approach

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

is that the type of methods[key], APIArray[K], is not seen as a single array type. When you call the push() method, it ends up widening K to its constraint, the union keyof APIArray, and therefore method[key].push is widened to the following union of functions:

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

A union of functions can only be safely called with an intersection of the union members' arguments (see the TS3.3 release notes describing support for calling unions of functions), and so the only thing methods[key].push will accept as an argument is something which is both a (num: number) =&gt; void and a (str: string) =&gt; void. But of course method is only going to be one of those, and it fails.

The compiler has lost track of the correlation between the type of methods[key].push and method. They're just uncorrelated union types, according to its analysis. Hence it is worried that maybe methods[key] will be an array of string-accepting methods while method will be a number-accepting method, and vice versa. We know this is unlikely, but the compiler can't see it.

(Notice I said unlikely and not impossible. This is technically a valid call:

// not likely
add(Math.random() &lt; 0.999 ? &quot;method1&quot; : &quot;method2&quot;, (s: string) =&gt; s.toUpperCase())

I'm not going to make this even longer by exploring why that is accepted and in which circumstances this can be prevented. The TS type system isn't fully sound and that things like this sometimes crop up, but it really isn't directly relevant to why your two code versions are treated differently.)

The general lack of support for "correlated unions" is described in microsoft/TypeScript#30581, and the same problem happens when you have union-constrained generics that the compiler can't analyze properly.


On the other hand, your second approach

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

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

works because the type of methods[key].push is seen as a single function that accepts an argument of type APIElement[K], which is exactly the type of method:

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

It's not a union because the compiler has better support for direct generic indexed accesses into mapped types. This support was added/strengthened in microsoft/TypeScript#47109 specifically as a way to help deal with correlated unions.

By writing the type of methods explicitly as a mapped type over APIElement, you have given the compiler a hint that it indexing into it with a generic index like key will result in a single generic thing, instead of a union of specific things.

So there you go.


It's not obvious that this difference should exist, and frankly it takes a lot of explaining and pointing to ms/TS#30581 and ms/TS#47109 whenever people run into this situation. Usually people hit this by being unable to write the version that works, a stumbling block which you've managed to overcome. But the issues are the same either way.

Playground link to code

答案2

得分: 0

第二个版本中,push 方法期望参数API[K],这是参数method的确切类型,因此它不必尝试解析任何泛型以查看是否有效。

在第一个版本中,因为有第二层间接性,typescript 会尝试解析泛型并确定push 需要接受与两个函数签名都兼容的参数,但该方法只能是其中一个。 (播放链接显示用基本类型替换泛型,并显示交集和联合)

我认为这主要是 TypeScript 的限制,即使明确表示接受push 方法相同的参数也会导致相同的错误:播放链接

function add&lt;K extends keyof typeof methods&gt;(key: K, ...method: Parameters&lt;typeof methods[K][&quot;push&quot;]&gt;) {
    methods[key].push(...method);
// 同样在这里 ^ 出现相同的错误
}

我没有比这更好的信息,希望其他更了解开发历史的人能发表意见,但我认为我有足够的信息来发布一个可能有所帮助的答案 Array类型的索引签名在使用`number`索引时失去了特定性。

英文:

In the second version the push method expects API[K] which is the exact type of the argument method so it doesn't have to try to resolve any generics to see if it is valid.

In the first version because there is a second level of indirection typescript tries to resolve the generic and determines that push needs to take an argument that is compatible with both function signatures but the method will only be one or the other. (playground link showing the generic replaced with base type and shows the intersection and union)

I believe this is mainly a limitation of typescript, even explicitly saying you take the same parameters of the push method gives the same error: playground

function add&lt;K extends keyof typeof methods&gt;(key: K, ...method: Parameters&lt;typeof methods[K][&quot;push&quot;]&gt;) {
    methods[key].push(...method);
// gives same error here ^
}

I don't really have better information than this, hopefully someone else with more knowledge of the development history can chime in but I figured I had enough to say that might help to post an answer Array类型的索引签名在使用`number`索引时失去了特定性。

huangapple
  • 本文由 发表于 2023年7月31日 23:23:04
  • 转载请务必保留本文链接:https://go.coder-hub.com/76805025.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定