当 noUncheckedIndexedAccess 为 true 时,通过长度缩小数组类型的类型安全方法

21

给定一个接受单个 string[] 参数 myArray 的函数。

如果我计算 .length 属性并且该属性大于 0,那么(假设我的变量不是伪装成的any),myArray[0] 不可能是未定义的。然而,如果我启用 noUncheckedIndexedAccess,它的类型将是 string | undefined

const myFunc = (myArray: string[]) => {
   if(myArray.length > 0) {
     const foo = myArray[0] // now typed at 'string | undefined'
   }
}

现在我可以更改if语句以评估myArray[0],并且undefined已从类型中移除,就像您所期望的那样。但是,如果我现在想检查数组的长度是否大于6怎么办?我不想对索引0-5执行相同的操作以正确地缩小类型。例如:

const myFunc = (myArray: string[]) => {
   if(myArray[0] && myArray[1] && myArray[2] && myArray[3] && myArray[4] && myArray[5]) {
      const foo = myArray[0] // now typed at 'string', but this is uggggly
   }
}

有没有更优雅的方式,可以根据数组的长度来缩小类型范围?还是我需要研究 TypeScript 代码库以进行贡献?


你可以编写一个用户定义的类型保护函数来实现这个效果,尽管它有点奇怪并且有边缘情况。因此,你可以使用hasLengthAtLeast(arr, 6)代替arr.length >= 6。如果你想了解更多信息,我可以在有机会时为你撰写答案。否则,我的回答就是“不行”。请告诉我你的想法。 - jcalz
说实话,我曾考虑过类型保护选项。如果坦率地说,我是想确认我的信念,即目前这种实现方法并不正确。也许这是一个有趣的借口来开始深入挖掘tsc代码库。 - Ben Wainwright
@BenWainwright,您能否在Playground中提供一个可重现的错误? - 0.sh
1
好吧……不是。我的问题并没有问任何错误的事情。我认为我已经表述得足够清楚了。 - Ben Wainwright
这是我可能建议的类型保护函数;我可能会写一篇答案,说你想要的不可能实现。同时提供链接到microsoft/TypeScript#38000,该问题被列为“过于复杂”,因此我怀疑你自己努力解决这个问题也不会有太大进展。稍后会有答案。 - jcalz
3个回答

25
正如你所怀疑的那样,TypeScript不会根据对其length属性的检查自动缩小数组类型。这在microsoft/TypeScript#38000中已经被建议过,但被标记为“太复杂”。看起来在此之前,microsoft/TypeScript#28837也曾被提出过,该问题仍然处于开放状态,并标记为“等待更多反馈”。也许你可以去那个问题并留下反馈,说明为什么这样做对你有帮助,以及当前的解决方案为什么不足,但我不知道它会产生多大的影响。无论如何,我怀疑TS团队现在不会接受拉取请求来实现这样的功能。
在没有任何自动缩小的情况下,你可以编写一个用户定义的类型保护函数,以达到你想要的效果。以下是一个可能的实现:
type Indices<L extends number, T extends number[] = []> =
    T['length'] extends L ? T[number] : Indices<L, [T['length'], ...T]>;

type LengthAtLeast<T extends readonly any[], L extends number> = 
  Pick<Required<T>, Indices<L>>

function hasLengthAtLeast<T extends readonly any[], L extends number>(
    arr: T, len: L
): arr is T & LengthAtLeast<T, L> {
    return arr.length >= len;
}
Indices<L>类型旨在接受一个单独的、相对较小的、非负的整数literal typeL,并返回长度为L的数组的数字索引的union。换句话说,Indices<L>应该是小于L的非负整数的联合。例如:
type ZeroToNine = Indices<10>
// type ZeroToNine = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

这种类型的工作原理是利用递归条件类型可变元组类型0L进行遍历。递归类型在处理正常情况时效果很好,但有一个大问题,如果传入的L过大、为分数、为负数、为数字或联合,则会出现异常或错误。这是这种方法的一个重要限制。
接下来,LengthAtLeast<T, L>需要一个数组类型T和一个长度L,并返回一个对象,该对象已知在长度至少为L的数组的所有索引处具有属性。就像这样:
type Test = LengthAtLeast<["a"?, "b"?, "c"?, "d"?, "e"?], 3>
/* type Test = {
    0: "a";
    1: "b";
    2: "c";
} */

type Test2 = LengthAtLeast<string[], 2>
/* type Test2 = {
    0: string;
    1: string;
} */

最后,hasLengthAtLeast(arr, len) 是类型守卫函数。如果它返回 true,那么 arr 的类型将从 T 缩小为 T & LengthAtLeast<T, L>。让我们看看它的实际应用:
const myFunc = (myArray: string[]) => {
    if (hasLengthAtLeast(myArray, 6)) {
        myArray[0].toUpperCase(); // okay
        myArray[5].toUpperCase(); // okay
        myArray[6].toUpperCase(); // error, possibly undefined
    }
}

看起来不错。编译器允许您将myArray[0]myArray[5]视为已定义,但myArray[6]仍然可能未定义。


无论如何,如果您决定使用类型守卫,您可能需要在复杂性和使用频率之间取得平衡。 如果您只检查长度的几个位置,那么仅使用null断言操作符(例如myArray[0]!.toUpperCase())可能是值得的,不必担心编译器为您验证类型安全性。
或者,如果您无法控制len的值,则可能不希望使用脆弱的递归条件类型,而是构建更健壮但不太灵活的东西(例如,仅适用于特定len值的重载类型守卫,如在microsoft/TypeScript#38000的评论中)。
这一切都归结于您的使用情况。

代码的Playground链接


哇,非常全面的答案,谢谢! - Ben Wainwright
readonly any[] 替换为 { length: number, [key: number]: unknown } 以使其适用于字符串!readonly 是多余的,只是以防万一。 - Alec Mev
抱歉,现在修改已经太晚了,使用 ArrayLike<unknown> 更加符合惯用语。 - Alec Mev
谢谢您的明确回答。但是,类型为LengthAtLeast<string[], 2>似乎不支持常规数组方法,例如array.every - Hssen
没错,LengthAtLeast 不会产生数组类型。如果您仍需要它是一个数组类型,则应使用类型保护 hasLengthAtLeast() 的输出与原始数组类型相交,如答案中所示。请参见此处。如果您需要 LengthAtLeast 是数组类型,则可以将其重新定义为我的版本 T & ,例如这样 - jcalz

0
如果我评估.length属性并且该属性大于0,那么(假设我的变量不是伪装成的任意变量),myArray[0]不可能是undefined。
请注意,严格来说,这对于稀疏数组确实是可能的。作为反例:
let xs /* : number[] */ = new Array<number>(100);
console.log(xs.length); // -> 100
console.log(xs[0]);     // -> undefined

在上面的代码中,`xs`的`length`属性是`100`,大于零,但`xs[0]`的值却是`undefined`。这是因为`xs`是一个稀疏数组。
`length`属性对应于一个密集数组中的元素数量。然而,如果数组是稀疏的,元素的数量严格小于`length`属性的值,并且元素可以从非零索引开始。

-3
一个可能的解决方法:
const [first, second] = myArray
assert(first)
assert(second)

first // can use normally here

一个优点是,assert可以在开发中运行,并且如果您想跳过运行时成本,则可以在生产中删除它。不过还是有点丑陋的。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接