英文:
Typescript doesn't infer subtype from Array.includes
问题
我试图让TypeScript正确推断一些变量,使用Array.includes(),但它不起作用。这里有一个容易理解的例子:
const Array = ['a', 'b', 'c'] as const
type SomeType = {
a: string
b: string
c: string
foo: boolean
bar: boolean
}
现在假设我有一个函数,它接收SomeType的一个键,但实际上我只想在我的键在我的Array中时执行某些操作:
const myAwesomeFunction = (field: keyof SomeType): void => {
if (Array.includes(field)) doStuff(field)
}
这里传递给doStuff函数的field的类型应该是typeof Array
,但实际上是keyof SomeType
。
我猜这种行为是由于一些不太清楚的边缘情况导致的,但我想找到一个解决方法,或者至少了解上面的代码的潜在问题,以及为什么Typescript认为这是一个错误。
我在GitHub上找到了一些相关的问题,但我不能完全理解它们:
英文:
I'm trying to get TypeScript to correctly infer some variables using Array.includes() but it doesn't work. Here's an easy to get example :
const Array = ['a', 'b', 'c'] as const
type SomeType = {
a: string
b: string
c: string
foo: boolean
bar: boolean
}
Now let's say I'm having a function that receive a key of SomeType but I actually only want to do stuff when my key is within my Array
const myAwesomeFunction = (field: keyof SomeType): void => {
if (Array.includes(field) doStuff(field)
}
Here the type of field given to the doStuff function should be typeof Array
but it actually is keyof SomeType
I guess this behavior is due to some obscure edge cases but I would like to find a work around for this or at least understand the underlying issue of the code above and why does Typescript think this is a mistake
I've found some issues related on GH but I couldn't really understand it all :
答案1
得分: 1
目前,includes()
数组方法的TypeScript类型定义不允许它作为类型保护器。这曾在microsoft/TypeScript#36275中提出,但被拒绝,因为实现起来太复杂。不允许的原因与includes()
返回false
时的含义有关。对于true
结果,很容易理解;如果arr.includes(x)
为true
,那么当然x
必须与arr
的元素相同的类型。但如果arr.includes(x)
为false
,通常不能说x
的类型不是arr
的元素类型。例如,请考虑以下情况:
function hmm(x: string | number) {
if (!([1, 2, 3].includes(x))) {
x.toUpperCase();
}
}
值x
的类型可以是string
或number
。如果[1, 2, 3].includes(x)
为true
,则我们知道x
是一个number
。但如果它为false
,我们绝对不能断定x
是一个string
。但如果includes()
是一个类型保护函数,情况将会如此。没有内置支持来说“噢,只忽略负案例”。这将需要类似“单边”或“细粒度”的类型保护函数,正如您在microsoft/TypeScript#15048中提到的一些问题中所请求的那样。
对于整个使用TypeScript的世界来说,这种更改将立即显著而令人不快,因为includes()
在整个TypeScript使用的代码中突然出现了非常奇怪的效果,其搜索的值被错误地缩小,可能一直到never类型。
因此,使其成为类型保护器的朴素方法将会有问题。我们可以尝试更复杂和仅在数组元素类型是联合类型的文字类型的情况下允许该函数充当类型保护器,但这开始看起来更加复杂和奇怪,涉及条件类型和this
参数,可能像这样:
interface ReadonlyArray<T> {
includes(
this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
searchElement: string
): searchElement is string & T;
}
这试图检测T
是否是字符串文字类型,但不是string
本身,然后使函数充当类型保护器。但即使那也不安全,因为没有要求数组包含其类型的每个元素:
function yuck(x: "a" | "c") {
const arr: readonly ("a" | "b")[] = ["b"]; // <-- 不包含 "a"
if (!(arr.includes(x))) {
x; // x 被错误地缩小为 "c"
({ c: 123 })[x].toFixed(1); // 运行时错误
}
}
这在实践中可能不太可能发生,但它是一种复杂性。即使我们不关心这一点,向全局可用的函数添加新的奇怪调用签名可能会对其他人的代码产生明显的影响(推断在重载函数中进行奇怪的事情,参见ms/TS#26591等无数其他问题)。为了支持这个用例,不值得为了整个TypeScript的努力和风险而添加新的奇怪调用签名。
如果您只是偶尔这样做,我建议您只是断言并继续:
const myAwesomeFunction = (field: keyof SomeType): void => {
if (array.includes(field)) {
doStuff(field as "a" | "b" | "c"); // 断言
} else {
field as "foo" | "bar"; // 断言
}
}
否则,您可以将其包装在自己的自定义类型保护函数中,并在需要此行为时使用它:
function myIncludes<T extends U, U>(
arr: readonly T[], searchElement: U
): searchElement is T {
return (arr as readonly any[]).includes(searchElement);
}
const myAwesomeFunction = (field: keyof SomeType): void => {
if (myIncludes(array, field)) {
doStuff(field);
} else {
field // (参数) field: "foo" | "bar"
}
}
或者,如果您真的想在代码库中的每个地方看到这种行为,您可以将该调用签名合并到Array<T>
接口中;这就是如果microsoft/TypeScript#36275被接受后的情况,但它不会影响其他人。
// declare global { // 如果您在模块中,请取消注释此行
interface ReadonlyArray<T> {
includes(
this: ReadonlyArray<T extends string ? string extends T ?
<details>
<summary>英文:</summary>
Currently the TypeScript typings for [the `includes()` array method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes) don't allow it to act as [type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) on its input. This was suggested at [microsoft/TypeScript#36275](https://github.com/microsoft/TypeScript/issues/36275), but declined as too complex to implement. The reason why it isn't allowed has to do with what it implies when `includes()` returns `false`. It's easy to reason about the `true` result; if `arr.includes(x)` is `true`, then of course `x` has to be the same *type* as the elements of `arr`. But if `arr.includes(x)` is `false`, then it is quite often a mistake to say that the type of `x` is *not* the type of the elements of `arr`. For example, consider what would happen here:
function hmm(x: string | number) {
if (!([1, 2, 3].includes(x))) {
x.toUpperCase();
}
}
The value `x` is of either a `string` or a `number` type. If `[1, 2, 3].includes(x)` is `true`, then we know `x` is a `number`. But if it is `false`, we absolutely cannot conclude that `x` is a `string`. But that's what would happen if `includes()` were a type guard function. There is no built-in support to say "oh just ignore the negative case". That would require something like "one-sided" or "fine-grained" type guard functions, as requested in [microsoft/TypeScript#15048](https://github.com/microsoft/TypeScript/issues/15048), one of the issues you mentioned.
This change would be immediately noticeable and unpleasant, as `includes()` calls throughout the TypeScript-using world would suddenly see very strange effects where their searched values got narrowed incorrectly, possibly all the way to [the `never` type](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#the-never-type).
----
So the naive approach to making it a type guard would be a problem. We could try to be more sophisticated and only allow the function to act as a type guard in circumstances like your code, where the array element type is a [union](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types) of [literal types](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types). But this starts to look more complicated and bizarre, involving [conditional types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html) and [`this` parameters](https://www.typescriptlang.org/docs/handbook/2/classes.html#this-parameters), maybe like this:
interface ReadonlyArray<T> {
includes(
this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
searchElement: string
): searchElement is string & T;
}
That tries to detect if `T` is a string literal type, but not `string` itself, and then it makes the function act as a type guard. But even *that* is not safe, since nothing requires an array to contain every element of its type:
function yuck(x: "a" | "c") {
const arr: readonly ("a" | "b")[] = ["b"]; // <-- doesn't contain "a"
if (!(arr.includes(x))) {
x; // x has been erroneously narrowed to "c"
({ c: 123 })[x].toFixed(1); // runtime error
}
}
That might not be likely in practice, but it's a complication. Even if we didn't care about that, adding new weird call signatures to globally available functions can have noticeable effects on other people's code. (Inference does weird things with [overloaded](https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads) functions, see [ms/TS#26591](https://github.com/microsoft/TypeScript/issues/26591) among countless others). It's just not worth the effort and risk to all of TypeScript just to support this one use case.
----
If you're only doing this once, I'd recommend you just [assert](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions) and move on:
const myAwesomeFunction = (field: keyof SomeType): void => {
if (array.includes(field)) {
doStuff(field as "a" | "b" | "c") // assert
} else {
field as "foo" | "bar" // assert
}
}
Otherwise you could wrap it in your own custom type guard function and use it when you need this behavior:
function myIncludes<T extends U, U>(
arr: readonly T[], searchElement: U
): searchElement is T {
return (arr as readonly any[]).includes(searchElement);
}
const myAwesomeFunction = (field: keyof SomeType): void => {
if (myIncludes(array, field)) {
doStuff(field)
} else {
field // (parameter) field: "foo" | "bar"
}
}
Or, if you really want to see this behavior everywhere in your code base, you could [merge](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#global-augmentation) that call signature into the `Array<T>` interface; this is what it would be like if microsoft/TypeScript#36275 had been accepted, but it doesn't affect anyone else.
// declare global { // uncomment this if you're in a module
interface ReadonlyArray<T> {
includes(
this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
searchElement: string
): searchElement is string & T;
}
// } // uncomment this if you're in a module
const myAwesomeFunction = (field: keyof SomeType): void => {
if (array.includes(field)) {
doStuff(field)
} else {
field // (parameter) field: "foo" | "bar"
}
}
[Playground link to code](https://www.typescriptlang.org/play?#code/MYewdgzgLgBAhgJwXAnjAvDA2gcjjgGhhwCNDjgcBdeCGUSKAKChQAcBTGAZRAFsOAFXZdMAbyYwpcAFwxoCAJZgA5pKkk5C5Wqn0tUJavUwAZiBBySFgDYc4YEyURXb9xwF8mTUwFcwwFCK4DAAFnx8ABQAHgZGKjAAPjBgvnwkHAgAlDASeoqmMJEAhJFYAIxEAExEAMxUAHTKwDa+ACYcEDFZPbkmetENUCAAqmycCADCcBAckVkA3CZeXkwdLYhcfgFBIW0g3FC+pqYxcqycIIWIyChYqemZVFlyAG4gim1LTMpQmaZwYBcABK9n2YBsKAAgkhUAAeQQAPj6+QCrQ6XX6UigoUUEDkoLg4MhMNuCJgHGifzAbTo2lUMAA-PJDDoKVSODS6IImSkOK9MjA5Dy5GB+ZlEQQsfJ7AhgKEAKJ2ARgKBxHQmF4yxDypUcFWwPEs+IwABkMEES1W20CwTAMD4KAAkmj2p1yZTqbSYCMiCNEZEbnIEGDwJCLVgqERZjrFcrOWqfVqY3K4-qEzAjTy8lIQ0cEPbA0haDAQ0Sw2gHHdnk1XRjIindfHVYsmF4GNAHdCAO6dfgcABi-lt4CqGCKpkUHBsbTkAGsOCgrjx+8JOFr3p8MMic5nCpFHS6Wm6ujdUERJ9O2r1d3p9odjqdLzOsssKTZZii9FJn20YAB6f8ijYRA4AEP5sjMKcZzkAAicwQFgpIYFg5wEFg5Y228G1dntFBfGAOczhQuAkOSWDgFgnJdw7WAg1LUMITQSJYNI5DUKoyNxywDiqCWfJ91KG5a2Petoh6ajpWiBYAKAiiMO-KRIjEfQYHKKpahgDwsiwaJGmGAdFGiDg2kicpFlk0t-CCAQKSQEAEEw1ZaK7KFewgfshx2O1x0iX950XZdeAENcOA3D4-3QHcTAKIozxQET0U6PzoOvSTFJge8jhOFKr1fPQPHfT9bx-VLLMiEDkHAzIcn8lCELIlC0IUqQVm8IA)
</details>
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论