TypeScript5.4 值得关注的新特性
2024年2月22日,TypeScript 发布了 5.4 版本的候选版本。其中,有两个新特性,非常值得我们关注,它们有效的提高了开发体验。
保留上次赋值后的类型收缩
在我们编写 typescript 代码时,通常需要检查变量,找出更具体的类型:
function foo(x: string | number) {
if (typeof x === 'string') {
// typescript 可以推断出当前 `x` 的类型为 `string`
return x.toUpperCase()
}
}
但是,在这里通常会遇到的一个痛点是,x
缩窄后的类型并不总是保留函数的闭包中:
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'.
// Property 'searchParams' does not exist on type 'string'.
return url.toString()
})
}
我们读这段代码时,可以明确知道 url
在进入 names.map()
回调函数中时是 URL
类型。 但是,在 typescript@5.4
之前,typescript 会假设 url
在进入 回调函数中后,其类型 URL
是不安全的,认为它可能 会在其他的地方发生变化。
而在这个例子中,回调函数始终在 url
完成赋值后创建,并且它也是最后一次赋值,所以 url
的类型总是 URL
。 typescript@5.4
利用这一点,使类型收缩变得更加智能。 在 非提升函数(non-hoisted functions) 中使用 参数 和 通过 let
声明的变量 时,typescript
检查器会 查找最后一个赋值点,如果能够找到,typescript
就可以安全的对该变量做类型收缩。
因此,在 typescript@5.4
中,上面的例子将不再报错。
但是请注意,如果变量在嵌套函数中的任何位置赋值,则不会进行缩窄分析。这是因为没有办法确定以后是否会调用该函数。
function printValueLater(value: string | undefined) {
if (value === undefined) {
value = 'missing!'
}
setTimeout(() => {
// 修改 `value`,即使是以不影响其类型的方式,也会使闭包中的类型收缩无效。
value = value
}, 500)
setTimeout(() => {
console.log(value.toUpperCase())
// ^^^^^
// error: 'value' is possibly 'undefined'.
}, 1000)
}
Utility Type: NoInfer
在 进行 泛型函数 调用时,typescript 可以根据传入的内容推断 参数类型:
function foo<T>(x: T) {}
// 我们可以告诉typescript `x` 的类型是 `number`
foo<number>(1)
// typescript 也可以推断 `x` 的类型是 `string`
foo('bar')
然而,typescript 并不总是很清楚要推断的 “最佳” 类型是什么。这可能导致 typescript
拒绝有效的调用、 接受有问题的调用,或者只是在捕获错误时报告更糟糕的错误消息。
例如,我们实现一个 createStreetLight
函数,它传入 颜色名称列表以及可选的默认颜色。
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}
createStreetLight(['red', 'yellow', 'green'], 'red')
当我们传入的 defaultColor
不在 colors
列表中时,会发生什么?
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}
// 这不符合预期,但还是通过了检查
createStreetLight(['red', 'yellow', 'green'], 'blue')
//
//
//
在这个调用中,类型推断会认为 "blue"
与 "red"
、"yellow"
和 "green"
都是 有效的, 因此,不会拒绝调用,而是推断类型 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'.
这是可行的,但是有点尴尬。因为 签名 createStreetLight
可能不会在其他地方使用泛型参数 D
。 虽然看起来还不错,但是在签名中只使用一次类型参数通常是一种 代码气味。
这就是 在 TypeScript@5.4
中引入 NoInfer<T>
的原因。 将类型用 NoInfer<...>
包围起来,会向 typescript
发送信号, 使其不要深入挖掘并匹配内部类型以寻找类型推断的候选对象。
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"
永远不会作为推理候选,并且类型检查器可以拒绝它。