In English /

Top-level await statements in TypeScript

cover image

I have a local TypeScript project, and there is a script in this project, that performs async tasks, where I naturally want to use top-level await statements:

publish.ts
import puppeteer from 'puppeteer'

import ads from '../ads'

import publishAd from './publish-ad'

const browser = await puppeteer.connect({
  browserWSEndpoint:
    'ws://127.0.0.1:9222/devtools/browser/9fa26331-a9ce-4af0-b335-7217edda9f0e',
})

await Promise.all(ads.map((ad) => publishAd(browser, ad)))

browser.disconnect()

Also I have a pretty basic TypeScript config, generated by tsc --init:

tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

I may also need to mention, that I'm running the latest release of NodeJS (at the moment of writing v21.1.0).

Historically we would wrap the entire code into async IIFE to use await like this:

publish.ts
(async () => {
  await ...
})()

And it still works! But hey, it's 2023 outside, and there should definitely be some more elegant way to do this. So, let's try.

If we take a naive approach and run it as is, without wrapping into async function, we get an error:

Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', or 'nodenext', and the 'target' option is set to 'es2017' or higher.

Ok, let's try to set module option to es2022, and target option to es2017 as compiler suggests:

tsconfig.json
{
  "compilerOptions": {
    "target": "es2017", 
    "module": "es2022", 
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

Now we are getting different compiler error:

Cannot find module 'puppeteer'. Did you mean to set the 'moduleResolution' option to 'nodenext', or to add aliases to the 'paths' option?

Huh. I love how compiler strikes us with new errors, when we do just what it suggest. Ok, let's try to add moduleResolution option:

tsconfig.json
{
  "compilerOptions": {
    "target": "es2017",
    "module": "es2022",
    "moduleResolution": "NodeNext", 
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

LOL, now we are getting error with config validation:

Option 'module' must be set to 'NodeNext' when option 'moduleResolution' is set to 'NodeNext'.

Alright. I actually don't like to use any "next"-ish values, and always prefer to stick with specific versions to avoid issues in future, but let's just do what compiler says this time (again):

tsconfig.json
{
  "compilerOptions": {
    "target": "es2017",
    "module": "NodeNext", 
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitAny": false,
    "skipLibCheck": true
  }
}

WTF. Now we are getting a new error from the compiler, blaming that the file where we use top-level await statement is a CommonJS module:

The current file is a CommonJS module and cannot use 'await' at the top level.

And it's not very clear what should we do at this point. We did everything as compiler suggested, and still no luck. Thank you, TypeScript.

It seems that we need to switch from CommonJS to ESM. For this we need to add type: "module to package JSON and update import statements to include .js file extension every time. Even if we import a .ts file 🤡. Ridiculous, isn't it? Moreover, some dependencies may not work, since they may not be provided as ESM builds. Anyway, let's try.

package.json
{
  "type": "module"
}

Jesus Christ! It seems to finally work! 🙏

I must admit that there were some issues in between, since I was using ts-node for running without compiling to file system, but after I removed ts-node and started to run compiled script from file system using simply node <script> command it started working.

Conclusion

It's not possible to have top-level await statements using TypeScript-only settings. This should be a combination of switching your node project to native Node module format (type: "module) and configuring TypeScript respectively. However, it would be really nice, TypeScript, if you did more intelligent checks during tsc --init and created correct config based on project settings (like checking type field in package.json). Thanks 😉.

send feedback