当 noUncheckedIndexedAccess 为真时,按长度缩小数组类型的类型安全方法

Type safe way of narrowing type of arrays by length when noUncheckedIndexedAccess is true

给定一个接受单个 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 代码库做贡献?

如您所料,TypeScript 不会根据 length 属性 的检查自动缩小数组的类型。这已经在 microsoft/TypeScript#38000, which is marked as "too complex". It looks like it had been suggested prior to that, at microsoft/TypeScript#28837 上提出过,它仍然开放并标记为“等待更多反馈”。可能您可以解决该问题并留下反馈,说明为什么这样的事情对您有帮助以及为什么当前的解决方案还不够,但我不知道它会产生多大的影响。无论哪种方式,我怀疑 TS 团队现在是否正在接受拉取请求来实现这样的功能。

在没有任何自动缩小的情况下,您可以改为编写具有您想要的效果的 user defined type guard function。这是一种可能的实施方式:

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> 类型旨在获取长度为 L 的数组的数字索引中的单个、相对较小、非负的整数数字 literal type L and return a union。另一种说法是 Indices<L> 应该是小于 L 的非负整数的并集。观察:

type ZeroToNine = Indices<10>
// type ZeroToNine = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

这种类型的工作原理是利用 recursive conditional types along with variadic tuple types0 走到 L。递归类型往往可以正常工作,直到它们不工作为止,这里的一个重要警告是,如果您传入一个太大、小数、负数或 number,或者联合。对于这种方法,这是一个很大的警告。

接下来,LengthAtLeast<T, L> 采用数组类型 T 和长度 L,以及 returns 一个已知在所有索引处都具有属性的对象至少长度为 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)是类型保护函数。如果它 returns 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] 仍然可能未定义。


无论如何,如果您决定使用类型保护,您可能希望在复杂性与您需要使用它的程度之间取得平衡。如果你只检查几个地方的长度,那么使用 non-null assertion operator 就像 myArray[0]!.toUpperCase() 可能是值得的,而不用担心让编译器为你验证类型安全。

或者,如果您无法控制 len 的值,那么您可能不想要一个脆弱的递归条件类型,而是构建更健壮但更不灵活的东西(比如可能是一个重载的类型保护仅适用于特定的 len 值,例如 in a comment on microsoft/TypeScript#38000).

一切都取决于您的用例。

Playground link to code

一种可能的解决方法:

const [first, second] = myArray
assert(first)
assert(second)

first // can use normally here

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