TypeScript5.4 New features worth paying attention to
On February 22, 2024, [TypeScript released a version 5.4 candidate] (https://devblogs.microsoft.com/typescript/announcing-typescript-5-4-rc/). Among them, there are two new features that deserve our attention, which effectively improve the development experience.
Keep the type shrinkage after the last assignment
When we write typescript code, we usually need to check variables to find more specific types:
function foo(x: string | number) {
if (typeof x === 'string') {
// typescript can infer that the current type of `x` is `string`
return x.toUpperCase()
}
}
However, one pain point that is usually encountered here is that the narrowed x
type does not always remain in the function's closure:
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()
})
}
When we read this code, we can clearly know that url
is of the URL
type when entering the names.map()
callback function. However, before typescript@5.4
, typescript assumes that url
' after entering the callback function, its type URL
is unsafe, thinking it may Will change elsewhere.
In this example, the callback function is always created after url
has completed the assignment, and it is also the last assignment, so the type of url
is always URL
. typescript@5.4
takes advantage of this to make type shrinking smarter. When using the parameters and variables declared via let
in non-hoisted functions*****, the typescript
inspector will Find the last assignment point. If you can find it, typescript
can safely type shrink the variable.
Therefore, in typescript@5.4
, the above example will no longer report an error.
But note that if the variable is assigned anywhere in the nested function, there is no narrowing analysis. This is because there is no way to determine whether the function will be called in the future.
function printValueLater(value: string | undefined) {
if (value === undefined) {
value = 'missing!'
}
setTimeout(() => {
// Modifying `value` will invalidate the type shrinkage in the closure even in a way that does not affect its type.
value = value
}, 500)
setTimeout(() => {
console.log(value.toUpperCase())
// ^^^^^^^
// error: 'value' is possible 'undefined'.
}, 1000)
}
Utility Type: NoInfer
When making a generic function call, typescript can infer the parameter type based on the passed in:
function foo<T>(x: T) {}
// We can tell typescript that the typescript `x` is `number`
foo<number>(1)
// Typescript can also infer that the type of `x` is `string`
foo('bar')
However, typescript is not always clear about what the "best" type is to be inferred. This may cause typescript
to reject valid calls, Accept the call in question, or just report a worse error message when catching the error.
For example, we implement a createStreetLight
function that passes in a list of color names and optional default colors.
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}
createStreetLight(['red', 'yellow', 'green'], 'red')
What happens when the defaultColor
we passed in is not in the colors
list?
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}
// This did not meet expectations, but passed the inspection
createStreetLight(['red', 'yellow', 'green'], 'blue')
//
//
//
In this call, type inference will assume that "blue", "red"
, "yellow" and "green"
are valid. Therefore, the call is not rejected, but the type C
is inferred as "red" | "yellow" | "green" | "blue"
. But this obviously didn't meet our expectations!
Currently we usually add a new type parameter, which is constrained by the existing type parameter.
function createStreetLight<C extends string, D extends C>(colors: C[], defaultColor?: D) {}
createStreetLight(['red', 'yellow', 'green'], 'blue')
// ^^^^^^^^^
// error:
// Argument of type '"blue"' is not assigned to parameter of
// type '"red" | "yellow" | "green" | undefined'.
This works, but is a bit awkward. Because the signature createStreetLight
may not use the generic parameter D
elsewhere. Although it looks pretty good, using type parameters only once in the signature is usually a code smell.
This is why NoInfer<T>
was introduced in TypeScript@5.4
. Surrounding the type with NoInfer<...>
will send a signal to typescript
. Make it not dig deeper and match internal types to find candidates for type inference.
function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {
// ...
}
createStreetLight(['red', 'yellow', 'green'], 'blue')
// ~~~~~~~~
// error:
// Argument of type '"blue"' is not assigned to parameter
// of type '"red" | "yellow" | "green" | undefined'.
Excluding the defaultColor
type for reasoning means that `"blue" is never a candidate for reasoning, and the type checker can reject it.