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

2

26

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?

Reggy answered 15/1, 2022 at 20:13 Comment(0)
P
11

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
[email protected] /home/me/folder
├── @types/[email protected]
├── @types/[email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── ...
└── [email protected]

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/"
    },
    ...
}
  • "type": "module" is explained here
  • The start command in the scripts section needs the option --es-module-specifier-resolution=node, necessary to enable ES Modules instead of the default CommonJS modules.
  • (For Jest only): --no-warnings is not mandatory, but --experimental-vm-modules is.

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:

  • timespan: import * as timespan from "timespan". The Timespan object is accessible using new timespan.Timespan(...) for example.
  • config: import config from 'config'.
  • util: with built-in packages, I think you can import any exported function as if you are using the CommonJS syntax. For instance, import { format } from 'util'.

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 types, you can still import them as before, like import { Type1, Type2 } from 'your_library'
  • for classes (but I think everything that comes from the value space), you first need to import a default object, and then use the object destructuring to extract the needed values.

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

Proudman answered 25/4, 2022 at 1:1 Comment(1)
There is a lot of additional content here that did not need to be included. It would be much better if you only solved the problem that OP was experiencing. Not including other problems that you have to solve. It makes your answer far less readable.Ventura
M
2

It occurred to me lately, as far as I know, CJS doesn't support top-level await, meaning that you need to use ts-jest with esm.

my jest.config.json

{
    "extensionsToTreatAsEsm": [".ts"],
    "globals": {
        "ts-jest": {
            "useESM": true
        }
    },
    "preset": "ts-jest/presets/default-esm",
    "moduleFileExtensions": ["js", "json", "ts"],
    "rootDir": ".",
    "testEnvironment": "node",
    "testRegex": "spec.ts$",
    "modulePathIgnorePatterns": ["<rootDir>/dist/", "<rootDir>/node_modules/"],
    "moduleNameMapper": {
        "^src/(.*)": "<rootDir>/src/$1"
    },
    "collectCoverage": true,
    "coverageDirectory": "./coverage",
    "collectCoverageFrom": ["src/**/*.(t|j)s"],
    "coveragePathIgnorePatterns": [
        ".module.ts$",
        ".spec.ts$",
        "src/database/",
        "src/server.ts"
    ],
    "verbose": true
}

You will also need to run jest with --experimental-vm-modules option

node --experimental-vm-modules -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --config jest.overall.json
Miraflores answered 24/3, 2022 at 7:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.