[TypeScript-tsconfig-noUncheckedIndexedAccess-02] 官方 PR 翻译

403 阅读6分钟

这个 PR#13778 的一个实现,#13778 是一个 2017年一月的issue。

#13778(2017.1)

当打开 strictNullChecks,TypeScript 对于一个 object 或者 array 的 index signatures 没有包含 undefined。这个问题在#9235,#13161,#12287, and#7140 (comment). 都讨论了。

例子:

const xs: number[] = [1,2,3];
xs[100] // number, even with strictNullChecks

然而,上述讨论的用户都不希望这个 case 发生。如果 index signatures 包含 undefined,则代码会更安全。这个代价对于增加类型安全是可以接受的。

对于 index signatures 包含 undefined 的例子:

const xs: number[] = [1,2,3];
xs[100] // number | undefined

这个 issue 获得了 500 个赞和 88 个喜欢。(我们国内的 TS 开发者没事也去看看 issue,点点赞,官方需要反馈。)

#39560 实现(2020.7)

这是一个 draft PR,目标是为了获得更多切实的反馈和讨论。是否大家需要使用这个特性在自己的代码里。NPM 安装支持版也会很快可用。

跟进目前 TS 的 CFA(CFA 指 control-flow analysis,是静态代码分析技术。) 的限制。我们没有很好地办法去修复这个问题。我们针对这个特性在实践中的情况持谨慎怀疑态度。我们对于这个问题的反馈是积极开放的。这样人们可以知道,这个 PR 的细节和如何去使用这个特性。这个标签不会和当前的任何 TS 发布版本有任何关联计划,也不要指望你的代码策略可以依赖这个特性。

介绍

在标签 --pedanticIndexSignatures(一开始还不叫 noUncheckedIndexedAccess) 下,以下变化会发生:

  • 可索引获取表达式(indexed access expressionobj[index] ,在读取时,会在自己的类型中增加 undefined,除非 index 是一个字符串字面常量(string literal),或者数字字面常量(numeric literal)且在之前的步骤发生了 narrowing(类型收紧)
  • 属性获取表达式(property access expressionobj.prop 如果没有匹配的属性名 prop 存在,但是 interface 里存在 string index signature,也会增加一个 undefined 除非在之前的步骤发生了 narrowing。

其他相关行为保持不变(注意,后续 pr 有可能更改这个行为。)

  • 可索引获取类型(Indexed access types),例如 type A = SomeType[number],保持之前的含义,即不增加 undefined。(其实今年的好几个 issue 都是这一条)
  • 对于 obj[index]和obj.prop 的写操作保持之前的行为(这一条造成了类型系统在某些写法上的不一致,因为确实不一致了。读写要分开来看。)

例子:

// 获得一个随机长度的 array
const arr = [1, 2, 3, 4, 5].slice(Math.random() * 5);
// Error, can't assign number | undefined to number
const n: number = arr[2];
if (arr[3]) {
    // OK, previous narrowing based on literal index is in effect
    arr[3].toFixed();
}
​
// 仍然不可以往这个数组里写 undefined。这个和不开也是保持一致的。
arr[6] = undefined;

例子:

const obj: { [s: string]: string } = { [(new Date().toString().substr(0, 3))]: "today" };
// 错误,今天可能不是 Tuesday
console.log(obj.Tue.toLowerCase());
if (obj.Fri) {
    // OK, previous narrowing on 'obj.Fri' in effect
   console.log(`${obj.Fri.toUpperCase()} IS FRIDAY`);
}

下面这个例子, non-literal indices 的 narrowing 的效果可以看出,这个特性并不像其他 TypeScript 的行为那么工程学(我发现 TS 官方不喜欢说优雅,比较喜欢说 ergonomic,工程学?),所以要额外做很多工作。

let arr = [1, 2, 3, 4];
for (let i = 0; i < arr.length; i++) {
    // Error; 'i' might be out of bounds
    const n: number = arr[i];
    if (arr[i] !== undefined) {
        // Still an error; dynamic indices do not cause narrowing
       // 纯个人认识,因为目前这个标记影响的类型在读写是不一致的,所以这种方法是不能实现 narrowing 的
      // 这个影响挺大的,意味着之前你经常 ok 的体操方式失效了。
        const m1: number = arr[i];
        i++;
        // This is sometimes a good thing, though!
        const m2: number = arr[i];
    }
}

更好的模式是用 for/of

for (const el of arr) {
    // OK; for/of assumes no out-of-bounds problems
    // for/of 假定没有超限问题。
    const n: number = el;
}

forEach

arr.forEach(el => {
    // OK; forEach won't exceed bounds
   // forEach 也不会超限
    const n: number = el;
});

赋值给一个 const

for (let i = 0; i < arr.length; i++) {
   // 后续的代码是在使用 el。
   // 但是这里其实是在 access 的时候,把 undefined 给了 el
   // 这个就是 typeof arr[i] 和 el 获得的 typeof arr[i] 是不一致的。好多 issue 就在讨论这个问题。
    const el = arr[i];
    if (el !== undefined) {
        // OK; constants may be narrowed
        console.log(el.toFixed());
    }
}
​
// 这个例子在补充一下,其实也是这个标签增加最大的区别:
// 这个代码在现在是不对的。因为编译器给  typeof arr[i] 增加 undefined 类型
// 个人建议:其实建议框架作者早开,这个影响真不小。但是细想,确实是个挺大的安全漏洞。
 const el: typeof arr[i]= arr[i];  

如果你的目标是一个新的环境,或者有合适的 polyfill,你可以使用 Object.entries:

for (const [el, i] of Object.entries(arr)) {
    console.log(`Element at index ${i} had value ${el.toFixed()}`);
}

其他提醒和讨论:

Naming

请注意:

  • 我们目前不计划把这个标签作为 --strict 家族的一部分,因为目前这个标签的行为还有局限,目前是不可能让这个标签以 strict 开头的
  • 但是,如果未来,我们发现可以让这个配置更可口(打开以后,几乎实现零误报),我们会把这个配置改为 --strictIndexSignatures
  • 我们不认为这个特性是一个应该默认支持的好特性。所以这个名字不可以是“这个特性奥利给必须上”这样的体现工程性的特性。
  • 这个特性会改变 string 和 numeric index 的行为,所以我们起名字会避免 Array 在名字里(所以现在叫 indexed?)

更多关于 Narrowing 的技术(“为啥我的 for 循环报错了?”)

一个大概率会出现的 FAQ ,为啥 C 风格的 for 循环不允许了。

let arr = [1, 2, 3, 4];
for (let i = 0; i < arr.length; i++) {
    // Error; 'i' might be out of bounds... but it isn't, right?
    const n: number = arr[i];
}

首先,TS 的 CFS 并不会追踪副作用,比如下面这个例子:

 if (arr[i] !== undefined) {
        arr.length = 0; // Side effect of clearing the array
        let x: number = arr[i]; // Unsound if allowed
    }

假设这种情况的 mutation 不存在作为一种优化策略是不对的。在实践中,除非你用 index,你会更多的用 for/of ,否则你会更多的用 for。而你用 index 的情形,一般是可能造成超限 index 的一些数学操作。

第二,任何 narrowing, 都是基于句法模式,如果把 e[i] 也加入到 narrowing 检查列表,会造成 5-10%的性能损失。单独看,好像不多,但是你和你最好的十个朋友,每个人都用这个特性,就给你的代码带来了 70% 的性能损失。你们喜欢这个损失么?(感觉 eslint 的机会来了~)

设计目标:稀疏 Arrays

稀疏 Arrays,就是你创建的 Arrays 不是连续的。形如:

let arr = [0];
arr[4] = 4;
// arr is "[0, empty × 3, 4]"

(伟大的)JS 对于稀疏 Arrays 的行为是不可预测的(开心不?)(每当这个时候,大家写 TS 的时候,不要忘了下面还是 JS,很多新手问,能不能光学 TS?答:不能)

for / of

for(const el of arr) console.log(el)
// Prints 0, undefined, undefined, undefined, 4

for/of对待稀疏 Array 会填充undefineds

forEach

arr.forEach(e => console.log(e))
// Prints 0, 4

forEach跳过了稀疏的元素。

...(spread)

const arr2 = [...arr];
arr2.forEach(e => console.log(e))
// Prints 0, undefined, undefined, undefined, 4

展开运算符会帮你填充。

针对这些行为,我们决定,假设稀疏 Array 在写的质量高的代码里是不存在的。在实践中,几乎不会用这个东西。让这个特性“运行正确”会有非常负面的副作用。实际上,TS 的编译器设置不会去改函数签名,所以, forEach 总会把自己元素的类型 T(而不是 T|undefined)传给回调。如果假设稀疏矩阵存在,(ts 编译器就要)[...arr].forEach(x => x.toFixed())然后遇到一个错误,所以只能是 [...arr] 产生了 (number | undefined)[](这样,你就需要在 forEach 里判断)。这样很烦人,就像 hack 一样,因为你不能用 !把 (T | undefined)[] 转换为 T[]。考虑到,const copy = [...someArr]非常常见,即便对于最严格的程序员,似乎也是不可口的设计。

设计目标:T[number] / T[string]是什么?

当使用这个特性时,一个可能的问题是:

type Q = (boolean[])[number]; 

Q 应该是 boolean? 还是 boolean | undefined?

其中 boolean 是能合法写入这个 array 的类型。

boolean|undefined 是你读到的类型。

假设这样的代码

// N.B. not a good function signature; don't do this in real code
function zzz<T extends unknown[]>(arr: T, i: number): T[number] {
    return arr[i];
} 

在这种模式下,就好像检查器要说 “zzz 可能输出一个超限 read,它需要一个声明”你可能写下面的代码:

// Allows out-of-bounds access to silently occur
function zzz1<T extends unknown[]>(arr: T, i: number): T[number] | undefined {
    return arr[i];
}
// - or - 
// Bounds checks for you
function zzz2<T extends unknown[]>(arr: T, i: number): T[number] {
    // TODO: Validate that 'i' is in bounds
    return arr[i]!; // ! - safety not guaranteed but we tried
}

下面是一个好的副作用,T[number]不会因为这个 flag 设定,行为有变化:

type A = { [n: number]: B }[number]
// A === B

总计:

这个特性影响还是挺大的,解构似乎也影响了,这个我还没测。ts 的编译标签,我的感受是,如果你是新项目,就不如早开,因为你不开,大概率你永远不会开了。ts 的每一个 strict 的影响面都是方方面面的,包含代码部分和 类型部分,而且随着支持的类型系统的变化,很可能一个标签就是 1000 个报错,看着成片的报错,任谁都难受吧。

实际上,到了 TypeScript 4.x 的版本,我们已经可以很容易把一个函数的输入和输出的所有情况,包括 pipe 和 compose 以后的情况 都用符合类型系统的方式表现出来。

从目前的进展看,下一步应该就是 读和写的区分了。显然读和写会影响的面是不一样的。以后所有的类型都有读类型和写类型?

已经有 unkown,下一步真的会有 missing 么?