Top-level Await for Typescript in 2022

5 minute readWritten on 26 December 2021

cover

When I started working with Typescript I fell in love with the way asynchronous and promise-based functions got adapted in the language syntax. I was able to write asynchronous code in a way that was easy to read and understand, and since it's integrated within the language you don't need to use any specific libraries like asyncio in Python.

I started a project a few months ago at work and I was excited to make the best use of promises in that particular scenario using NodeJS and Typescript. The problem with that was that once I created a variable that I was planning to use in different modules/files, I couldn't export them to other parts of the application, nor was I able to unit test them, since it all had to be wrapped under an async function. Javascript had this problem too, but Google spent the time to integrate top-level await in V8 more than 2 years ago, albeit with some changes required to use it.

Let us consider that you have an Express app that depends on some secrets, like the location of our database:

1// index.ts
2
3let startApp = async () => {
4 const secrets = await requestSecrets();
5 const app = await createApp(secrets);
6
7 app.listen(port, function () {
8 console.log(`🚀 App is listening on port ${port}!`);
9 });
10}

If you wrap your code in an async function, you can't export it, and making this function return everything we need to export could work, but it's not a good practice, and we need to have another file in which we use the listen method. In this situation, I felt like everything could be solved if I could get rid of those anonymous functions and use the await keyword instead.

Unfortunately, I found myself spending a lot of time figuring out different node versions, flags, tsconfig settings, whether it is even possible at all. Microsoft said Typescript 3.8 is supposed to support top-level await feature that ECMAScript 2017 introduced, but I was not able to get it to work. Unsurprisingly, the proposal is set for publication in 2022, next year. But that doesn't mean we stop there. Luckily after many Stackoverflow pages and GitHub issues I was able to find a solution that worked.

#Project Configuration

To start off, we need to set some things up in our package.json and tsconfig.json files. Add an attribute to the top-level in package.json:

1// package.json
2"type": "module"

And add this configuration in tsconfig.json:

1// tsconfig.json
2{
3 "compilerOptions": {
4 "target": "es2017",
5 "module": "esnext",
6 "moduleResolution": "node",
7 "esModuleInterop": true,
8 }
9}

With these changes applied, your project will behave differently. Read below a note from Microsoft's documentation on some of the changes you'd expect.

Note there's a subtlety: top-level await only works at the top level of a module, and files are only considered modules when TypeScript finds an import or an export. In some basic cases, you might need to write out export {} as some boilerplate to make sure of this.

Remember that many of these settings are following this post are experimental, and they might change with time. It will likely be much easier to use top-level await in the future starting from 2022.

We may also need to add a flag to NodeJS to enable top-level await, depending on your project. I've added the flag to the following example build and start scripts:

1// package.json
2"scripts": {
3 "build": "tsc",
4 "start": "node --es-module-specifier-resolution=node ./dist/index.js",
5}

#Jest

For Jest, we can use ts-jest, but the package only recently got support for top-level await. You can add and/or modify this config in package.json, but the esm settings are essential:

1// package.json
2"jest": {
3 "preset": "ts-jest/presets/default-esm",
4 "modulePathIgnorePatterns": [
5 "<rootDir>/dist/",
6 "<rootDir>/node_modules/"
7 ],
8 "extensionsToTreatAsEsm": [
9 ".ts"
10 ],
11 "globals": {
12 "ts-jest": {
13 "useESM": true
14 }
15 }
16}

Now that we handled jest's configuration, we need to make a script to execute once we run yarn run test. Unfortunately, we still need to pass some flags to NodeJS to make it work. This might change with future Typescript targets, but as of writing experimental-vm-modules is still required, and esnext doesn't get rid of this error. Our test script in package.json will look similar to this:

1// package.json
2"scripts": {
3 "test": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js"
4}

Special thanks to Herb Caudill for sharing the solution he found while digging for solutions in ts-jest's repository. It allows us to configure Jest properly, and luckily as of writing this post, we are no longer pinned to the next version for the fix.

#Nodemon

For Nodemon, we can use the ts-node ESM loader with some additional settings to NodeJS. The following script will do:

1// package.json
2"scripts": {
3 "dev": "nodemon -x node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm index.ts"
4}

#Adapting the code

Now we can write our code without those pesky anonymous functions, and we can use top-level await. Rewriting our example from above, we can now export the secrets and app like so:

1// index.ts
2const secrets = await requestSecrets();
3const app = await createApp(secrets);
4
5app.listen(port, function () {
6 console.log(`🚀 App is listening on port ${port}!`);
7});
8
9export { secrets, app };

#Conclusion

I hope you'll find this post useful, and I hope you'll find it useful in your projects too. Even after some years since top-level await has been defined in the language, it is still difficult to adopt it in your projects. That's why I've created a repo on GitHub where I've put the code. You can clone it and get started!