Many popular NodeJS packages are moving towards ESM-only support. This leads to the necessity to build applications with ESM support as well.
Some dependencies may be built for both, ESM and CommonJS. If, however, deeply in the dependency tree there is an ESM-only package, the application itself must be ESM. It does not suffice to make sure that your own packages are built for ESM if you choose to stay on CommonJS.
NestJS itself currently does not officially support ESM and there are also no plans to change it.
There is a way, though, to start an ESM-based NestJS application by setting "type": "module"
in the package.json
among other things.
One challenge remains, though: Jest. A regular ESM NodeJS application has, unfortunately, other requirements to the production code than tests that are using Jest.
The TypeScript compiler does not bring all of the features that are needed into a single bundle. Therefore, it needs to be replaced by a different compiler. Let's see how we can configure the application and build system to work with ESM and Jest at the same time.
[TOC]
First, let's configure Typescript to not build to Javascript files and not get into the way of the replacement compiler:
tsconfig.json
:
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"noEmit": true,
"esModuleInterop": true,
"noErrorTruncation": true,
"experimentalDecorators": true
},
"exclude": ["dist", "node_modules"]
}
This config basically disables the Typescript compiler (with noEmit
) but still allows to use tsc
for type checking while still factoring in ESM and NestJS's decorators.
SWC is a fast compiler/bundler that allows to use NestJS with ESM packages. First, install it along with Jest-related packages:
npm i -D \
@swc/cli \
@swc/core \
@swc/plugin-transform-imports \
@swc/jest \
jest \
@types/jest
Then, create the configuration file of SWC in your application root (next to package.json
):
.swcrc
:
{
"exclude": "node_modules/",
"sourceMaps": "inline",
"module": {
"type": "nodenext"
},
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"topLevelAwait": true,
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
}
}
}
This configuration tells SWC to handle decorators like intended by NestJS.
It also sets the target to esnext
as well as module to nodenext
which allows usage of ESM packages.
Note: sourceMaps
needs to be set to "inline"
because they are incorrect when set to true
.
Using the debugger will be difficult without "inline"
.
ESM requires different import paths to reduce the guesswork of node and to allow the same import style in the web.
When importing a file, say foo.ts
, you now need to import the compiled foo.js
:
- import { ... } from "./foo"
+ import { ... } from "./foo.js"
You also cannot import the implicit index
file any more.
Say you have a file foo/index.ts
, you now need to import this file directly:
- import { ... } from "./foo"
+ import { ... } from "./foo/index.js"
All local imports need to be changed in the whole application code.
The first of these two can be easily done with regex-search and replace with these inputs:
search: (from "\.[^"]+)"
replace: $1.js"
One requirement for our tests is usually to compile the Typescript-based test files on the fly when running them.
We do not want to compile and run tests in two distinct steps.
This requires Jest to transform the code.
By default, this is done by ts-node
only using the tsconfig.json
.
However, there is one more caveat: Jest cannot handle imports with .js
suffix.
Bummer.
Rewriting every import in your code base to remove the .js
suffix is not a valid option because you want to test the exact same code that you ship.
Or is it?
SWC has a plugin that transforms imports.
We use this plugin to remove the .js
suffix only during runtime of the tests.
For it to work, we need to merge this into the Jest config.
When using SWC, however, the global jest
constant is not available out of the box.
You can set it with this file, though (next to the package.json
):
jest-setup.mjs
:
import { jest } from "@jest/globals";
global.jest = jest;
With the following snippet of the Jest config, we use SWC to transform the tests and use the same compiler config as the production code (except for the .js
suffix):
jest.esm-config.cjs
:
const fs = require("node:fs");
const swcConfig = JSON.parse(fs.readFileSync(`${__dirname}/.swcrc`, "utf-8"));
module.exports = {
testEnvironment: "node",
moduleFileExtensions: ["js", "json", "ts"],
transform: {
"^.+\\.(t|j)sx?$": [
"@swc/jest",
{
...swcConfig,
/* custom configuration in Jest */
jsc: {
...(swcConfig.jsc ?? {}),
experimental: {
...(swcConfig.jsc?.experimental ?? {}),
plugins: [
...(swcConfig.jsc?.experimental?.plugins ?? []),
[
"@swc/plugin-transform-imports",
{
"^(.*?)(\\.js)$": {
skipDefaultConversion: true,
// remove js suffix from local imports:
transform: "{{matches.[1]}}",
},
},
],
],
},
},
},
],
},
extensionsToTreatAsEsm: [".ts", ".tsx"],
setupFiles: ["<rootDir>/jest-setup.mjs"],
};
jest.config.cjs
:
const jestEsmConfig = require("./jest.esm-config.cjs");
module.exports = {
...jestEsmConfig,
// whatever your options for path patterns, mocks, coverage etc. may be
testMatch: ["**/*.spec.ts"],
resetMocks: true,
collectCoverage: true,
...
};
This Jest config injects jest
as a global and makes it compatible with ESM as well as the .js
-suffixed imports.
Otherwise, it treats the code exactly the same as the production build.
If you need to use a separate config for, for instance, end to end (E2E) tests, you can simply derive from the main Jest config:
jest.e2e-config.cjs
:
const jestConfig = require("./jest.config.cjs");
module.exports = {
...jestConfig,
testMatch: ["**/*.e2e-spec.ts"],
forceExit: true,
};
The application itself needs to be a module
for it to be able to import ESM packages.
Set "type": "module"
in your package.json
.
The build
script first checks the types and then compiles to Javascript.
If you have any additional .ts
files that are not inside the src/
directory, you need to list them as well (Example: swc src generated/openapi.ts --out-dir dist
).
The test
script sets flags to run Jest with ESM support.
package.json
:
{
"name": "...",
"version": "...",
"type": "module",
"scripts": {
"build": "tsc && swc src --out-dir dist",
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --config=jest.config.cjs",
"test-e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --config=jest.e2e-config.cjs",
...
},
...
Error classes in ESM work different from error classes in CommonJS where you would use es6-error
and extend from its default export ExtendableError
.
In ESM, you need to extend from this:
error-base.ts
:
export class ErrorBase extends Error {
constructor(message?: string) {
super(message);
Object.defineProperty(this, "name", {
value: this.constructor.name,
configurable: true,
writable: true,
});
Error.captureStackTrace(this, this.constructor);
}
}
like this:
not-found-error.ts
:
import { ErrorBase } from "./error-base.js";
export class NotFoundError extends ErrorBase {}
While the config
package supports using config/default.{js,ts}
, it is not supported with ESM.
The config
package imports/requires your config files dynamically during runtime.
ESM, however, does not allow importing/requiring modules during runtime.
They would need to be loaded with await import()
which config
does not do.
So config files need to be static JSON or YAML.
If you are using Visual Studio Code, you can use this snippet as a launch config:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Server",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/dist/src/index.js",
"preLaunchTask": "npm: build",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
},
{
"type": "node",
"request": "launch",
"name": "Jest",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--config=jest.config.cjs"],
"env": {
"NODE_OPTIONS": "--experimental-vm-modules",
},
},
{
"type": "node",
"request": "launch",
"name": "Jest E2E",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--config=jest.e2e-config.cjs"],
"env": {
"NODE_OPTIONS": "--experimental-vm-modules",
},
},
// the following config allows to use the debugger in the vscode-jest extension
{
"type": "node",
"name": "vscode-jest-tests.v2.esm-commonjs-nestjs-jest-example",
"request": "launch",
"env": {
"NODE_OPTIONS": "--experimental-vm-modules",
},
"args": [
"--config=jest.esm-config.cjs",
"--runInBand",
"--watchAll=false",
"--testNamePattern",
"${jest.testNamePattern}",
"--runTestsByPath",
"${jest.testFile}",
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"program": "${workspaceFolder}/node_modules/.bin/jest",
},
],
}
If you are using the vscode-jest
extension, you need to add this to your workspace config:
"jest.nodeEnv": {
"NODE_OPTIONS": "--experimental-vm-modules"
}
There is a lot of setup work required to allow usage of NestJS decorators with ESM while still being able to test the application with Jest. The above configuration snippets provide you with a way to set up your application. With these you can finally upgrade popular packages to their current versions.
While it could be possible to transform local import statements during compile time to not require the .js
suffix and save yourself some effort, it is advised to simply add the .js
.
ESM is the future of the Javascript ecosystem and your code should not introduce such hacks to cling on to legacy concepts.