TypeScript 5.4:带来新的类型和一些 Break Change
大家好,我是 ConardLi。
大家好,最近 TypeScript 发布了 5.4 Beta 版本,其中包含了一些值得关注的新特性以及一些 Break Change,我们一起来看下吧:
优化闭包中的类型收窄
“类型收窄” 在 TypeScript 中是一个常见的类型推断过程,基于我们可能进行的某些检查或条件,TypeScript 能够自动推断出变量的具体类型,这就使得该变量的类型范围被“缩小”或者说“窄化”。这项特性可以帮助我们编写更加准确、安全的代码。
function uppercaseStrings(x: string | number) {
if (typeof x === "string") {
// TypeScript在这里知道'x'是一个'string'
return x.toUpperCase();
}
}
一个常见的痛点是,我们在闭包函数里是感知不到这些被收窄后的类型的。
function getUrls(url: string | URL, names: string[]) {
if (typeof url === "string") {
url = new URL(url);
} return names.map(name => {
url.searchParams.set("name", name)
// ~~~~~~~~~~~~
// error!
// Property 'searchParams' does not exist on type 'string | URL'.
return url.toString();
});
}
在代码中,我们首先检查了 url 的类型,如果 url 是字符串(即 typeof url === "string"),我们把它转化为 URL 对象。在这个语句块中,TypeScript 能够理解 url 已经不再是一个字符串,而是一个 URL 对象,因此我们可以在后面调用 URL 对象的 searchParams 属性。
可是,在数组的 map 方法中,TypeScript 不能保证 url 的类型已经窄化为 URL,因为他无法确定在回调函数被执行的当下,url是否仍然是 URL 对象,这是因为在函数的闭包中,变量可能会被之后的代码改变,因此 TS 认为这种类型窄化是不安全的。
但其实在这个例子中,这个箭头函数肯定是在对 url 进行类型变更后被创建的,并且对 url 的类型变更是最后的赋值操作,所以 url 在这个函数中的类型就是我们赋值的类型。
因此,TypeScript 5.4 做了改进,当参数和 let 变量在非提升函数中使用时,类型检查器将查找最后一个赋值点。如果找到一个,TypeScript 可以从包含该函数的外部安全地窄化,那上面的代码示例就可以正常工作了。
但是还需要注意一点,如果我们是在嵌套函数中的任何地方对变量进行了赋值,类型收窄还是不起作用的。这是因为我们没有办法确保是否会在以后调用该函数。
function printValueLater(value: string | undefined) {
if (value === undefined) {
value = "missing!";
} setTimeout(() => {
// Modifying 'value', even in a way that shouldn't affect
// its type, will invalidate type refinements in closures.
value = value;
}, 500);
setTimeout(() => {
console.log(value.toUpperCase());
// ~~~~~
// error! 'value' is possibly 'undefined'.
}, 1000);
}
Github 上有个 Issue 就是讨论这个问题的,感兴趣可以看看:https://github.com/microsoft/TypeScript/pull/56908
我之前的文章:什么是鸭子🦆类型? 其实也是属于类型收窄的一种。
工具类型:NoInfer
在 TypeScript 中,有时候我们写代码的时候不需要明确告诉它变量是什么类型,TypeScript 会自动根据我们给的值来推断出类型。这个过程我们称之为类型推断。
当你调用泛型函数时,系统能够根据你传入的参数来推断类型。例如,我们有一个函数 doSomething 如下所示:
function doSomething<T>(arg: T) {
// ...
}
我们既可以明确指定 T 应该是 string 类型:
doSomething<string>("hello!");
又可以让 TypeScript 自己推断 T 的类型:
doSomething("hello!");
但是,这种情况可能有个问题,有时候并不清楚应该推断出什么类型才是 “最准确” 的类型。这可能会导致 TypeScript 错误地拒绝有效的调用,还会接受有问题的调用,或者在捕获到错误时报告不正确的异常信息。
举个例子,比如我们有一个 createStreetLight 函数,它接受一个颜色名称的列表以及一个可选的默认颜色。
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}createStreetLight(["red", "yellow", "green"], "red");
如果我们传入的 defaultColor 不在原始 colors 数组中会怎样呢?
// 这是不正确的,但是被允许了!
createStreetLight(["red", "yellow", "green"], "blue");
在这个调用中,类型推断决定 "blue" 和 "red"、"yellow"、"green" 一样是合法的类型。因此,而不是拒绝这个调用,TypeScript 推断 C 的类型为 "red" | "yellow" | "green" | "blue"。我们可以说这个推断是不准确也不应该的。
我们目前的处理方式之一是添加一个由现有类型参数约束的单独类型参数。
function createStreetLight<C extends string, D extends C>(colors: C[], defaultColor?: D) {
}createStreetLight(["red", "yellow", "green"], "blue");
// ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.
这个方法虽然行得通,但是有点别扭,因为 D 在 createStreetLight 的签名中可能不会再被用到。虽然在本例中还算可接受,但在签名中只使用一次类型参数通常是不太好的代码。
这就是为什么 TypeScript 5.4 引入了一个新的 NoInfer<T> 工具类型。将类型用 NoInfer<...> 包裹起来,就是告诉 TypeScript 不要深入探查内部类型来寻找推断候选类型。
使用 NoInfer,我们可以这样重写 createStreetLight:
function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {
// ...
}createStreetLight(["red", "yellow", "green"], "blue");
// ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.
排除 defaultColor 用于推断的类型意味着 "blue" 根本就不会成为一个推断候选,这样类型检查器就可以拒绝它。
Object.groupBy 、 Map.groupBy
TypeScript 5.4 为 JavaScript 的新静态方法 Object.groupBy 和 Map.groupBy 添加了类型声明。
Object.groupBy 接受一个可迭代对象,以及一个函数,这个函数决定每个元素应该放置在哪个“组”中。函数需要为每个不同的组制作一个“键”,然后 Object.groupBy 使用这个键来创建一个对象,其中每个键都映射到一个包含原始元素的数组中。
以下 JavaScript 代码:
const array = [0, 1, 2, 3, 4, 5];const myObj = Object.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even": "odd";
});
基本上等同于写成这样:
const myObj = {
even: [0, 2, 4],
odd: [1, 3, 5],
};
而 Map.groupBy 类似,但它产生的是一个 Map 对象,而不是普通对象。如果你正在处理期望 Map 的 API,或者你需要使用任何类型的键进行分组(不仅仅是可以用作 JavaScript 属性名的键),这可能会更好一点。
const myObj = Map.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even" : "odd";
});
和之前一样,你可以用等效的方式创建 myObj:
const myObj = new Map();myObj.set("even", [0, 2, 4]);
myObj.set("odd", [1, 3, 5]);
注意,在上面的 Object.groupBy 示例中,产生的对象使用了所有可选属性。
interface EvenOdds {
even?: number[];
odd?: number[];
}const myObj: EvenOdds = Object.groupBy(...);
myObj.even;
// ~~~~
// 在 'strictNullChecks' 下访问此属性时出错。
这是因为没有办法保证 groupBy 产生了所有的键。
注意:只有将
target配置为esnext或调整你的lib设置后,才能访问这些方法。
Break Change
第一个 Break Change 是条件类型约束相比以前更准确了。
在 TypeScript 的早期版本中,当我们使用条件类型(就是那种基于条件分支决定类型的表达式)时,默认的行为有时会显得有些草率。具体来说,它会简单地检查一个泛型参数的约束,也就是这个参数应该符合的条件,而不是去具体考虑实际情况下类型的所有可能性,这样可能导致一些不太精确的类型判断。
来看一个例子,比如我们有以下的类型判断表达式:
type IsArray<T> = T extends any[] ? true : false;
这个类型的意思是:如果 T 是一个数组类型,那么 IsArray<T> 就是 true,否则就是 false。
当你在函数中使用这个类型表达式时,如下:
function foo<U extends object>(x: IsArray<U>) {
let first: true = x; // 报错
let second: false = x; // 报错,但在以前的版本中不会
}
在这段代码里,我们期望 x 为 true 或 false。但是,根据 U 的具体类型(只要符合 object 的约束),IsArray<U> 的结果可能在代码执行之前是无法确定的。
在 TypeScript 5.4 之前的版本中,对于 first 和 second 的赋值,TypeScript 会仅仅基于 U 的约束来进行类型推断而不会充分考虑可能的情况。这样有时会允许一些在逻辑上应该出错的代码通过类型检查。
而在新版的 TypeScript 5.4 中,类型系统变得更加严谨和精确了。它不会急于仅根据泛型参数 U 的约束来决定 IsArray<U> 类型是 true 还是 false。它会更谨慎地分析所有可能的情况,如果不能确定 T 总是或者永不扩展至 Foo,它会为条件类型创建一个联合类型来表示所有可能性。
第二个可能的 Break Change 是现在的 TypeScript 在处理类型之间的交集时变得更加灵敏了。它会仔细考量类型变量(也就是泛型参数)和像字符串这样的基本类型之间的关系,来决定他们的交集是否有意义。
比如,你有这样一个函数 intersect,它可以将两种类型结合成交集:
declare function intersect<T, U>(x: T, y: U): T & U;
现在,想象你写了这样一个 foo 函数:
function foo<T extends "abc" | "def">(x: T, str: string, num: number) {
let a = intersect(x, str); // 旧的TypeScript会说这是 'T & string' 类型,但现在只认定为 'T'
let b = intersect(x, num) // 旧的TypeScript会说这是 'T & number' 类型,但现在会告诉你这是 'never'
let c = Math.random() < 0.5 ? // 以前说是 '(T & "abc") | (T & "def")' 类型的联合,但现在它只会是 'T'
intersect(x, "abc") :
intersect(x, "def");
}
在新版 TypeScript 中,它会比较两个类型,如果看起来没有什么共同点或者交集没有什么用,它会直接告诉你交集是 never,这比以前简单判断要精准多了。
另一个改进是 TypeScript 现在会更精确地检查字符串类型是否可以分配给模板字符串类型的占位符:
function a<T extends {id: string}>() {
let x: `-${keyof T & string}`; // 这里 `keyof T & string` 就是确认 T 的键是否也是字符串 x = "-id"; // 以前这会报错,但现在TypeScript同意这样赋值
}
这种行为是更好一点的,但是可能会导致我们现在使用类似条件类型的结构的代码发生变化。
最后
参考:https://devblogs.microsoft.com/typescript/announcing-typescript-5-4-beta/