英文:
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<(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);
}
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 '((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'.
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) => 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);
}
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.
英文:
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.
答案2
得分: 0
在第二个版本中,push
方法期望参数API[K]
,这是参数method
的确切类型,因此它不必尝试解析任何泛型以查看是否有效。
在第一个版本中,因为有第二层间接性,typescript 会尝试解析泛型并确定push
需要接受与两个函数签名都兼容的参数,但该方法只能是其中一个。 (播放链接显示用基本类型替换泛型,并显示交集和联合)
我认为这主要是 TypeScript 的限制,即使明确表示接受push
方法相同的参数也会导致相同的错误:播放链接
function add<K extends keyof typeof methods>(key: K, ...method: Parameters<typeof methods[K]["push"]>) {
methods[key].push(...method);
// 同样在这里 ^ 出现相同的错误
}
我没有比这更好的信息,希望其他更了解开发历史的人能发表意见,但我认为我有足够的信息来发布一个可能有所帮助的答案
英文:
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<K extends keyof typeof methods>(key: K, ...method: Parameters<typeof methods[K]["push"]>) {
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
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论