Nodejs, TypeScript, ts-node-dev & top-level await
Asked Answered
D

4

55

I have spent an hour now searching for a solution to a new error after needing top-level await. Everything else I have tried so far did not solve the error such as adding "type": "module" to the package.json file.

The message of the error is Cannot use import statement outside a module when starting the service. If I revert the change "module": "ESNext", to "module": "commonjs",, it works fine (except the await keywords have to be removed and somehow refactored to work without await).

In addition, I use ts-node-dev to run the service which can be seen in the package.json file.

  • The new package I need is kafkajs.
  • node version: v14.9.0
  • TypeScript version: 4.0

package.json

{
  "name": "",
  "version": "1.0.0",
  "description": "microservice",
  "main": "src/index.ts",
  "author": "",
  "type": "module",
  "license": "MIT",
  "scripts": {
    "dev": "NODE_ENV=development tsnd --respawn --files src/index.ts",
    "prod": "NODE_ENV=production tsnd --respawn --transpile-only --files src/index.ts",
    "test": "mocha --exit -r ts-node/register tests/**/*.spec.ts",
    "eslint": "eslint src/**/*.ts"
  },

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "outDir": "dist",
    "sourceMap": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "ts-node": {
    "files": true,
    "transpileOnly": true
  },
  "include": ["src/**/*.ts", "declariations/**.d.ts"],
  "exclude": ["node_modules", ".vscode"]
}
Dullish answered 30/10, 2020 at 14:39 Comment(0)
D
93

TL;DR: Don't use ECMAScript modules with ts-node or ts-node-dev (yet); just refactor the top-level await out

Today I tumbled down the same rabbit hole, starting with the innocuous error:

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

As a result, I felt inclined to edit my tsconfig.json and set module to esnext, which in turn forced me to set moduleResolution to node and finally add type: module to my package.json. What I failed to realize (and IMO they shouldn't suggest this in the error message-- you can just simply refactor the top-level await out), that this switches the module resolution strategy of NodeJS from CommonJS to ESM. This is actually a big deal for many reasons:

I feel that at this time (december 2020) the NodeJS ecosystem is still in transition from the classic CommonJS modules to the new ECMAScript modules. As a result other tech like ts-node or ts-node-dev are transitioning as well and support can be flaky. My advice is to stick with CommonJS until the dust has settled and these things "just work" out of the box.

Defilade answered 11/12, 2020 at 19:44 Comment(13)
My answer is essentially a "You can't" with an explanation why not. Just because the answer isn't what you expected doesn't make it a bad answer.Defilade
@MartinDevillers - Would you mind providing an example of what you mean by "factoring out the top level await"? I'm trying to do something like this in a Node.js worker, and would like to use a top-level await there but am unsure of the approach to take. Thanks!Cloudlet
The quickest approach is to wrap your code in a Immediately-invoked Function Expression (IIFE). This introduces a new scope so your await is no longer at the top-level. Example: (async function() { await someAsyncFunction(); })();Defilade
@MartinDevillers That doesn't do the same thing. You're calling the async function and then throwing away its future, not awaiting it.Kook
@Kook That's true. It depends on the specifics of your use-case if this is acceptable or not. Note that there is no way to actually await async code from sync code, unless you implement a busy-wait which is strongly discouraged. This is why in JavaScript you don't find something like Promise.wait(), that you may find in other languages like Java. The point is to never block the main-thread.Defilade
@Kook the only place where it makes any difference is in module execution order, a top level async IIFE has been a valid solution for years before the introduction of top-level await.Garboard
@OlegValteriswithUkraine Yeah, the executor does keep running until a top-level async IIFE executes to completion, but there is nothing in either the language spec or the node.js docs (that I can find) that guarantees it will do that. It would be perfectly plausible for the garbage collector to discard the promise returned by the prompt invocation of such an IIFE, cancelling execution of the async work before it ever starts. If you're saying there is also no such guarantee for top-level await, that's terrifying.Kook
@MartinDevillers I consider that a serious gap in the language, both because sometimes you need to block the main thread, and because (as discussed in my reply to Oleg) there needs to be an official, guaranteed way to run some computation to completion before the executor terminates. (This may make more sense if you stop thinking about browsers; my primary interest in node.js is as a scripting language for the traditional Unix shell environment, where short-lived batch processes are the norm.)Kook
@Kook I think the gap here is that JavaScript was designed to be run in the context of a long-running process like a web browser or NodeJS API, so they designed it to discourage blocking that long-running process. In your case, you are using JavaScript as a standalone shell scripting language which is a niche application compared to its typical use case. Thus you run into quirks like these. However, I'd argue if you don't care about blocking the main thread then why use async/await at all? You can just write all your scripts using synchronous API's and avoid this problem to begin with.Defilade
@Kook Btw NodeJS doesn't keep running until all unresolved Promises are resolved. It keeps running while there are tasks left on the event loop. In other words, the mere existence of a Promise won't keep the process alive. So for instance if you have a async method like await new Promise((resolve) => { console.log(42); }); it will terminate before ever printing to console.Defilade
@MartinDevillers I can't "just write all my scripts using synchronous APIs" because some of the synchronous APIs I would need don't exist. (For instance, and just off the top of my head, runtime ES6 import is async only.)Kook
@MartinDevillers Right, that's exactly the issue. echo 'new Promise((resolve) => {console.log(42);});' | node -- - does print 42 before exiting, but what seems ti be missing is a construct that guarantees some outermost promise will be forced all the way to completion before exiting (and then probably process.exit() should be called using the value of the promise as the argument).Kook
@Kook You're right and I realize I made a mistake in my last comment. I've created a quick sample to better illustrate the problem: stackblitz.com/edit/node-jyag9m?file=index.js In this sample there is a log statement at line 10 that will either print or not depending on the internals of the inner Promise. Completely agree that this behavior is problematic. The official GitHub repo for node has an issue on this topic that you may find interesting: github.com/nodejs/node/issues/22088 This includes some interesting workarounds (read: dirty hacks)Defilade
W
15

Update Node v18.12.0

You can now also use the built-in --watch flag from Node.

Assuming your entry file is src/index.ts:

node --watch --loader ts-node/esm --experimental-specifier-resolution node src

Original Answer

I know, the question is quite old already, but for people passing by this issue like I did earlier today:

I just managed to get it to work. Not with ts-node-dev directly, but with the combination of nodemon and ts-node.

You need to install nodemon and ts-node and then, assuming your entry file is src/index.ts, you can use this command:

nodemon --ext ts --loader ts-node/esm --experimental-specifier-resolution node src

(Tested with Node v16.13.0)

This was the key post for me to figure it out: https://github.com/TypeStrong/ts-node/issues/1007

Wilkens answered 31/10, 2021 at 18:50 Comment(2)
I used it without nodemon: "ts-node --esm --experimental-specifier-resolution node src/app.ts"Sapid
Also, package.json needs "type": "module" entry.Sapid
B
7

It should be possible to use ts-node and have top level await.

Could you try ts-node --esm {file} as your command.

I'm currently using top level await and ts-node on this code here.

I've also set my tsconfig target as es2022 and my package.json type to module.

Bolduc answered 8/12, 2022 at 11:32 Comment(0)
B
0

Know that there is already an accepted answer to this question, but I think that is outdated right now (as of Dec 2022). Still ts-node-esm support is marked experimental. You can easily set up the flow you want though.

https://github.com/TypeStrong/ts-node#native-ecmascript-modules for the relevant section in ts-node and a code base example if needed: https://github.com/vishnup95/ts-node-top-level-await/tree/main.

// tsconfig.json

{

    "extends": "ts-node/node16/tsconfig.json",
    "ts-node": {
        "esm": true,
        ....
        "compilerOptions": {
            "module": "ESNext",
            "target": "ES2017"
        }
    },
    "compilerOptions": {
        // Just to make the lint shut up!
        "module": "ESNext",
        "target": "ES2017"
    }
}

Don't forget "type": "module" in package.json

Please consider changing the accepted answer/updating it.

Brigitte answered 3/12, 2022 at 19:11 Comment(3)
I still get "Cannot use import statement outside a module".Heavenward
Which version of node are you running? The repo works well. I checked again.Brigitte
This deserves a higher vote, in 2024, it works fine with top level await, and with import (reply to first comment here). It also saves the hassle of having to add --esm every time one uses ts-node by adding it to the config.Sarto

© 2022 - 2024 — McMap. All rights reserved.