这个 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 expression)obj[index] ,在读取时,会在自己的类型中增加 undefined,除非 index 是一个字符串字面常量(string literal),或者数字字面常量(numeric literal)且在之前的步骤发生了 narrowing(类型收紧)
- 属性获取表达式(property access expression)obj.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 么?