node.jstypescriptjestjstop-level-await

TS Jest won't accept top-level-awaits with NodeJS 16 & TypeScript


I'm trying to update my NodeJS 12 & TypeScript app to Node16, one of the reasons is the need to use top-level-awaits.

The code compiles correctly after the update, but Jest won't accept the specific top-level-await code:

ts-jest[ts-compiler] (WARN) src/xxx.ts:11:17 - error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', or 'nodenext', and the 'target' option is set to 'es2017' or higher.

11 const project = await client.getProjectId();
                   ~~~~~
 FAIL  src/xxx.test.ts
  ● Test suite failed to run

    Jest encountered an unexpected token

package.json:

{
    "name": "functions",
    "scripts": {
        "lint": "eslint --ext .js,.ts .",
        "lint:fix": "eslint --ext .js,.ts . --fix",
        "build": "tsc -b",
        "build:watch": "tsc-watch",
        "serve": "...",
        "test": "env-cmd -f .env.json jest --runInBand --verbose"
    },
    "type": "module",
    "engines": {
        "node": "16"
    },
    "main": "lib/index.js",
    "exports": "./lib/index.js",
    "dependencies": {
        "test": "^1.0.0"
    },
    "devDependencies": {
        "@google-cloud/functions-framework": "^2.1.0",
        "@types/busboy": "^1.3.0",
        "@types/compression": "1.7.2",
        "@types/cors": "^2.8.12",
        "@types/express": "^4.17.12",
        "@types/express-serve-static-core": "^4.17.28",
        "@types/google-libphonenumber": "^7.4.23",
        "@types/jest": "^27.4.0",
        "@types/jsonwebtoken": "^8.5.7",
        "@types/luxon": "^2.0.9",
        "@types/node-zendesk": "^2.0.6",
        "@types/sinon": "^10.0.6",
        "@types/supertest": "^2.0.11",
        "@types/swagger-ui-express": "^4.1.3",
        "@types/uuid": "^8.3.4",
        "@types/yamljs": "^0.2.31",
        "@typescript-eslint/eslint-plugin": "^5.9.0",
        "@typescript-eslint/parser": "^5.9.0",
        "env-cmd": "^10.1.0",
        "eslint": "^8.6.0",
        "eslint-config-google": "^0.14.0",
        "eslint-config-prettier": "^8.3.0",
        "eslint-plugin-import": "^2.25.4",
        "eslint-plugin-prettier": "^4.0.0",
        "eslint-plugin-react-hooks": "^4.2.1-beta-a65ceef37-20211130",
        "jest": "^27.4.7",
        "prettier": "^2.5.1",
        "sinon": "^12.0.1",
        "supertest": "^6.2.1",
        "ts-jest": "^27.1.3",
        "ts-node": "^10.4.0",
        "tsc-watch": "^4.6.0",
        "typescript": "^4.5.4"
    },
    "private": true
}

tsconfig.json:

{
  "compilerOptions": {
    "module": "es2022",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "outDir": "lib",
    "sourceMap": false,
    "strict": true,
    "target": "es2021",
    "moduleResolution": "Node",
    "resolveJsonModule": true
  },
  "compileOnSave": true,
  "include": [
    "src"
  ],
  "ts-node": {
    "moduleTypes": {
      "jest.config.ts": "cjs"
    }
  }
}

jest.config.ts:

export default {
  roots: [
    '<rootDir>/src'
  ],
  setupFiles: ['./src/setupJestEnv.ts'],
  preset: 'ts-jest',
  testEnvironment: 'node',
  testPathIgnorePatterns: ['/node_modules/'],
  coverageDirectory: './coverage',
  coveragePathIgnorePatterns: ['node_modules', 'src/database', 'src/test', 'src/types'],
  globals: { 'ts-jest': { diagnostics: false } },
};

I can't really understand what's wrong here. Any idea?


Solution

  • After some research, I can certainly tell you that we are still far from a reliable solution. The main problem is that the top-level await is available

    when the 'module' option is set to 'es2022', 'esnext', 'system', or 'nodenext', and the 'target' option is set to 'es2017' or higher.

    Even if you set both module and target to es2022 in the tsconfig.json, you will have to understand and solve a lot of errors, as you have experienced. I have just found a configuration that works for me now, but it might present other problems I am not aware of.


    $ node --version
    v17.7.2
    $ npm list
    myproject@1.0.0 /home/me/folder
    ├── @types/jest@27.4.1
    ├── @types/node@17.0.25
    ├── config@3.3.7
    ├── jest@27.5.1
    ├── sequelize@6.18.0
    ├── timespan@2.3.0
    ├── ts-jest@27.1.4
    ├── ts-node@10.7.0
    ├── ...
    └── typescript@4.6.3
    

    I have other libraries, but they did not have to be readapted. I am using jest for testing and sequelize as ORM: they need to be configured appropriately. Let's start from the package.json:

    {
        "type": "module",
        "scripts": {
            "prestart": "npx tsc",
            "start": "NODE_ENV=production node --es-module-specifier-resolution=node out/index.js",
            "test": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js tests/"
        },
        ...
    }
    

    As for the Jest configurations:

    $ cat jest.config.ts
    import type { Config } from '@jest/types';
    
    const config: Config.InitialOptions = {
        globals: {
            "ts-jest": {
                useESM: true
            }
        },
        preset: 'ts-jest/presets/default-esm',
        roots: ["tests/"],
        modulePathIgnorePatterns: [
            "<rootDir>/node_modules/"
        ]
    };
    
    export default config;
    

    You can add new properties, of course, but this is more or less the minimal configuration. You can find the other presets here (in case you need them), while the globals section for ts-jest is furtherly explained here.


    Let's see the tsconfig.json, which is a bit more complex:

    $ cat tsconfig.json
    {
        "ts-node": {
            "moduleTypes": {
                "jest.config.ts": "cjs"
            }
        },
        "compilerOptions": {
            "lib": ["ES2021"],
            "module": "ESNext",
            "esModuleInterop": true,
            "moduleResolution": "node",
            "target": "ESNext",
            "outDir": "out",
            "resolveJsonModule": true
        },
        "compileOnSave": true,
        "include": ["src/"],
        "exclude": ["node_modules", "**/*.spec.ts"]
    }
    

    Again, it is more or less the minimal configuration, I guess module and target properties can be initialized with the other alternatives in the error shown when trying to use the top-level await, but I am not sure they are all working.


    Now to the worst part, the packages' compatibility with ES modules. I have not studied enough to tell you when an import from a certain package needs to be adapted, but I will provide you with some examples.

    Built-in libraries/config/timespan

    Built-in libraries should be ok as they are. In my project I am also using config and timespan:

    sequelize (and many others...)

    I am using sequelize with typescript, but I had to change the import syntax. The error I have encountered is:

    SyntaxError: The requested module 'sequelize' does not provide an export named 'DataTypes' at ...

    The problem is that experimental modules do not support named exports. The new import syntax depends on what you need to import:

    For example, with sequelize:

    import Sequelize, 
        { InferAttributes, InferCreationAttributes, CreationOptional }
        from 'sequelize';                         // InferAttributes, InferCreationAttributes, CreationOptional are types 
    const { Op, DataTypes, Model } = Sequelize    // these are classes
    
    class UserModel extends Model<InferAttributes<UserModel>, 
        InferCreationAttributes<UserModel>> 
    {
        declare id: CreationOptional<number>
        ...
    }
    

    Additional sources