What’s new in TypeScript 4.5

Written by Lawrence Eagles ✏️

Introduction

TypeScript 4.5 ships with some amazing features out of the box. These include enhanced Awaited type that improves asynchronous programming, supported lib from node_modules for painless upgrades, and performance optimization by using the realpathSync.native function.

Also, in the beta release, TypeScript added ES modules support for Node 12. However, Microsoft believes this feature would need more “bake time.” Consequently, this feature is only available via an --experimental flag in the nightly builds of TypeScript.

In this article, we would look at the new additions to the feature-packed TypeScript 4.5.

Let’s get started in the next section.

New Features

The Awaited type and Promise improvements

Inspired from the challenges experienced when working with JavaScript inbuilt methods like promise.all, this feature introduces a new Awaited type useful for modeling operations like await in async functions, or the .then() method on a Promise.

This feature adds overloads to promise.all, promise.race, promise.allSettled, and promise.any to support the new Awaited type.

This new type of utility adds the following capabilities:

  1. 1. Recursive unwrap
    1. Does not require PromiseLike to resolve promise-like “thenables”
    2. Non-promise “thenables” resolve to never

Lastly, some different use cases are:

// basic type = string 
type basic = Awaited<Promise<string>>;

// recursive type = number 
type recursive = Awaited<Promise<Promise<number>>>;

// union type = boolean | number 
type union = Awaited<string | Promise<number>>;

Supporting lib from node_modules

TypeScript ships with a series of declaration files — files ending with.d.ts. These files don’t compile to .js; they are only used for type-checking.

These type declaration files represent the standard browser DOM APIs and all the standardized inbuilt APIs, such as the methods and properties of inbuilt types like string or function, that are available in the JavaScript language.

TypeScript names these declaration files with the pattern lib.[something].d.ts, and they enable Typescript to work well with the version of JavaScript our code is running on.

The properties, methods, and functions available to us depend on the version of JavaScript our code is running on (e.g., the startsWith string method is available on ES6 and above_.

The target compiler setting tells us which version of JavaScript our code runs on and enables us to vary which lib files are loaded by changing the target value.

Consider this code:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
  }
}

In the code above, the target value is es5. Thus, ES6 features like startsWith and the arrow function cannot be used in our code. To use ES6 features, we can vary which lib files are loaded by changing es5 to es6.

One of the downsides of this approach is that when we upgrade TypeScript, we are forced to handle changes to TypeScript’s inbuilt declaration files. And this can be challenging with things like DOM APIs that change frequently.

TypeScript 4.5 introduces a new way to vary the inbuilt lib. This method works by looking at a scoped @typescript/lib-* package in node_modules:

"dependencies": {
    "@typescript/lib-dom": "npm:@types/web"
  }
}

In the code above, @types/web represents TypeScript’s published versions of the DOM APIs. And by adding the code above to our package.json file, we lock our project to the specific version of the DOM APIs.

Consequently, when TypeScript is updated, our dependency manager’s lockfile will maintain a version of the DOM types.

Tail-recursion elimination on conditional types

TypeScript ships with heuristics that enable it to fail gracefully when compiling programs that have infinite recursion or nonterminating types. This is necessary to prevent stack overflows.

In lower versions, the type instantiation depth limit is 50; this means that after 50 iterations, TypeScript considers that program to be a nonterminating type and fails gracefully.

Consider the code below:

type TrimLeft<T extends string> = T extends ` ${infer Rest}` ? TrimLeft<Rest> : T;

type Test = TrimLeft<"                                                  fifty spaces">;

In the code above, the TrimLeft type eliminates leading spaces from string type, but if this leading space exceeds 50 (as in the case above), TypeScript throws this error:

// error: Type instantiation is excessively deep and possibly infinite.

In version 4.5 however, the instantiation limit has been increased to 100 so the above code works. Also, because in some cases the evaluation of recursive conditional types may require more than 100 iterations, TypeScript 4.5 implements tail-recursive evaluation of conditional types.

The result of this is that when a conditional type ends in another instantiation of the same conditional type, the compiler would evaluate the type in a loop that consumes no extra call stack. However, after a thousand iterations, TypeScript would consider the type nonterminating and throw an error.

Disabling import elision

In lower versions, by default, TypeScript removes imports that it interprets as unused — in cases that TypeScript cannot detect you are using an import. And this can result in unwanted behavior.

An example of this case is seen when working with frameworks such as Svelte or Vue.

Consider the code below:

<!-- A .svelte File -->
<script>
import { someFunc } from "./some-module.js";
</script>

<button on:click={someFunc}>Click me!</button>

<!-- A .vue File -->
<script setup>
import { someFunc } from "./some-module.js";
</script>

<button @click="someFunc">Click me!</button>

In the code above, we have a sample Svelte and Vue file. These frameworks work similarly as they generate code based on markup outside their script tags. On the contrary, TypeScript only sees code within the script tags, consequently it cannot detect the use of the imported someFunc module. So in the case above, TypeScript would drop the someFunc import, resulting in unwanted behavior.

In version 4.5, we can prevent this behavior by using the new --preserveValueImports flag. This flag prevents TypeScript from removing any import in our outputted JavaScript.

type modifiers on import names

While --preserveValueImports prevents the TypeScript compiler from removing useful imports, when working with type import, we need a way to tell the TypeScript compiler to remove them. Type imports are imports that only include TypeScript types and are therefore not needed in our JavaScript output.

Consider the code below:

// Which of these is a value that should be preserved? tsc knows, but `ts.transpileModule`,
// ts-loader, esbuild, etc. don't, so `isolatedModules` issues an error.
import { FC, useState } from "react";
const App: FC<{ message: string }> = ({ message }) => (<div>{message}</div>);

In the above code, FC is a type import, but from the syntax, there is no way to tell the TypeScript compiler or build tools to drop FC and preserve useState.

In earlier versions of TypeScript, TS removes this ambiguity by marking type import as type-only:

import type { FC } from "react";
import { useState } from "react";

But TypeScript 4.5 gives us a DRY and cleaner approach:

import {type FC, useState} from "react";

Template string types as discriminants

This feature enablesTypeScript to recognize and successfully type-check values that have template string types.

Consider the code below:

type Types =
    {
      type: `${string}_REQUEST`;
    }
  | {
      type: `${string}_SUCCESS`;
      response: string;
    };

function reducer2(data: Types) {
    if(data.type === 'FOO_REQUEST') {
        console.log("Fetcing data...")
    }
   if (data.type === 'FOO_SUCCESS') {
     console.log(data.response);
   }
}
console.log(reducer2({type: 'FOO_SUCCESS', response: "John Doe"}))

In the code above, TypeScript is unable to narrow the Types down because it is a template string type. So accessing data.response throws an error:

- Property 'response' does not exist on type 'Types'.
- Property 'response' does not exist on type '{ type: `${string}_REQUEST`; }'.

However, in TypeScript 4.5 this issue is fixed, and TypeScript can now successfully narrow values with template string types.

Private field presence checks

With this feature, TypeScript supports the JS proposal for ergonomic brand checks for private fields. This proposal enables us to check if an object has a private field by using the syntax below:

class Person {
  #password = 12345;
  static hasPassword(obj: Object) {
     if(#password in obj){
         // 'obj' is narrowed from 'object' to 'Person'
        return obj.#password;
     }

     return "does not have password";
  }
}

In the code above, the hasPassword static method uses the in operator to check if obj has a private field and returns it.

The problem is that earlier versions of TypeScript would return a strong narrowing hint:

- Property '#password' does not exist on type 'Object'.

However, TypeScript 4.5 provides support for this feature.

Import assertions

TypeScript 4.5 adds support for the import assertion JavaScript proposal. This import assertion feature enables us to pass additional information inline, in the module import statement. This is useful in specifying the type of module.

This feature works with both normal import and dynamic import as seen below:

// normal import
import json from "./myModule.json" assert { type: "json" }; 

// dynamic import
import("myModule1.json", { assert: { type: "json" } });

Experimental nightly-only ECMAScript module support in Node.js

With the release of ES modules, JavaScript gives us a standard module definition. And as a result, frameworks like Node.js that are built using a different module definition (like CommonJS) need to provide support ES module. This has been difficult to completely accomplish because the foundation of the Node.js ecosystem is built on CommonJS and not ES modules.

With this feature, TypeScript provides support for ES modules when working with Node.js, but this feature is not available directly on TypeScript 4.5. You can use this feature currently only on the nightly builds of TypeScript.

New snippet completions

TypeScript 4.5 enhances the developer experience with these code snippets:

Snippet completions for methods in classes

With TypeScript 4.5, when implementing or overriding methods in a class, you get snippet autocompletion.

According to the documentation, when implementing a method of an interface, or overriding a method in a subclass, TypeScript completes not just the method name but also the full signature and braces of the method body. And when you finish your completion, your cursor will jump into the body of the method.

Snippet completions for JSX attributes

With this feature, TypeScript improves the developer experience for writing JSX attributes by adding an initializer and smart cursor positioning as seen below:

Better editor support for unresolved types

This is another feature that is aimed at improving the developer experience. With this feature, TypeScript preserves our code even if it does not have the full program available.

In older versions, when TypeScript cannot find a type it replaces it with any:

However, with this feature, TypeScript would preserve our code (Buffer) and give us a warning when we hover over it as seen below:

--module es2022

This new module setting enables us to use top-level await in TypeScript. And this means we can use await outside of async functions.

In older versions, the module options could be none, commonjs, amd, system, umd,es6, es2015, esnext.

Setting the --module option to esnext or nodenext enables us to use top-level await, but the es2022 option is the first stable target for this feature.

Faster load times with realpathSync.native

This is a performance optimization change that speeds up project loading by 5–13 percent on certain codebases on Windows, according to the documentation.

This is because the TypeScript compiler now uses the realpathSync.native function in Node.js on all operating systems. Formally this was used on Linux, but if you are running a recent Node.js version, this function would now be used.

const assertions and default type arguments in JSDoc

With this feature, TypeScript enhances its support for JSDoc.

JSDoc is an API documentation generator for JavaScript, and with the @type tag, we can provide type hints in our code.

With this feature, TypeScript enhances the expressiveness of this tool with type assertion and also adds default type arguments to JSDoc.

Consider the code below:

// type is { readonly name: "John Doe" }
let developer = { name: "John Doe" } as const;

In the code above, we get a cleaner and leaner immutable type by writing as const after a literal — this is const assertion.

In version 4.5, we can achieve the same expressiveness in JavaScript by using JSDoc:

// type is { readonly prop: "hello" }
let developer = /** @type {const} */ ({ name: "John Doe" });

Conclusion

TypeScript 4.5 is packed with features and enhancements of both the language and the developer experience. You can get more information on TS 4.5 here.

I hope after this article you are ready to start working with TypeScript 4.5.

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

24