Skip to content

TypeScript5.4 New features worth paying attention to

About 797 wordsAbout 3 min

typescript

2024-02-23

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.