Skip to content

Instantly share code, notes, and snippets.

@danpetitt
Last active November 12, 2024 13:11
Show Gist options
  • Save danpetitt/37f5c966886f54e457ece4b08d66e404 to your computer and use it in GitHub Desktop.
Save danpetitt/37f5c966886f54e457ece4b08d66e404 to your computer and use it in GitHub Desktop.
Typescript, Jest and ECMAScript Modules

Typescript, Jest and ECMAScript Modules (ESM)

Package.json

Add the type property to package.json to ensure modules are supported:

{
  "type": "module",
}

Ensure you are using latest jest and ts-jest; I have been testing using the following versions:

  • "ts-jest": "^29.0.5"
  • "jest": "^29.4.3"

tsconfig

Use esnext module type; this is my basic tsconfig for building:

{
  "extends": "@tsconfig/node18/tsconfig.json",
  "compilerOptions": {
    "module": "esnext",
    "resolveJsonModule": true,
    "removeComments": true,
    "outDir": "../dist",
    "sourceMap": false,
    "types": ["node", "jest"],
    "strict": false,
    "esModuleInterop": true
  },
  "include": [
    "./**/*",
    "./**/*.json"
  ],
  "exclude": [
    "tsconfig.json",
    "tsconfig-build.json"
  ]
}

Filenames and Imports

There is a fundamental difference to how you name and import your files when using ESM.

What you do will depend if you want to use:

  • mostly esm, but have some common js files
  • mostly use commonjs, but have some esm files

Basically, I leave all my files as .ts and will use them all as ESM; and the handful of files that need to be treated as CommonJS will be named .cjs (or .mts).

The following from the Typescript Manual helps give more information determination of this.

The type field in package.json is nice because it allows us to continue using the .ts and .js file extensions which can be convenient; however, you will occasionally need to write a file that differs from what type specifies. You might also just prefer to always be explicit.

Node.js supports two extensions to help with this: .mjs and .cjs. .mjs files are always ES modules, and .cjs files are always CommonJS modules, and there’s no way to override these.

In turn, TypeScript supports two new source file extensions: .mts and .cts. When TypeScript emits these to JavaScript files, it will emit them to .mjs and .cjs respectively.

Furthermore, TypeScript also supports two new declaration file extensions: .d.mts and .d.cts. When TypeScript generates declaration files for .mts and .cts, their corresponding extensions will be .d.mts and .d.cts.

Using these extensions is entirely optional, but will often be useful even if you choose not to use them as part of your primary workflow.

Once this has been determined, the last thing you need to do is change your imports to reference the js built files:

import { export } from './source.file.js'

This can be annoying, but once you have changed your first import, most editors (I use VSCode) will auto add any further imports with the extension included. However it is something you need to keep a track of when you create a new file and add the first import.

Any files which have to be treated as CommonJS files should be renamed .cjs; common files will be eslintrc.cjs, or any integration startup scripts like pre-test-integration.cjs.

Jest

Change your test start instruction in package.json to set the experimental-vm-modules support which is required as at v18.8.0 of node:

"scripts": {
    "pretest": "npm run lint && tsc -p tsconfig.json --noEmit",
    "test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose --coverage --config jest-unit.config.json"
  }

Transformation

As we are using ts-jest as our transformer; the setup config, either as a module.export or via a .json file (as shown below), needs some additional properties and changes to the structure of the setup properties.

As you can see, we need to include an additional moduleNameMapper so that imports work; a new extensionsToTreatAsEsm property and to change the structure of transform to include the new .cjs files but also allow us to tell ts-jest to emit ESModule compatible code.

  "moduleNameMapper": {
    "^(\\.{1,2}/.*)\\.js$": "$1",
  },
  "extensionsToTreatAsEsm": [".ts"],
  "transform": {
    "^.+\\.(mt|t|cj|j)s$": [
      "ts-jest",
      {
        "useESM": true
      }
    ]
  },

One last thing is we now need to import the jest global variable by adding to the top of our test files:

import { jest } from '@jest/globals';

References

@pavelkornev
Copy link

@JackHamer09 the official ts-jest/presets/default-esm preset defines transforms like in the examples of this gist, not via globals.

@danpetitt

Once this has been determined, the last thing you need to do is change your imports to reference the js built files:
import { export } from './source.file.js'
This can be annoying, but once you have changed your first import, most editors (I use VSCode) will auto add any further imports with the extension included. However it is something you need to keep a track of when you create a new file and add the first import.

This is actually configurable in VSCode via importModuleSpecifierEnding setting.


I have created my own playground/working boilerplate (as minimalistic as possible) with few improvements:

  • Support for absolute paths when importing modules (to avoid hell of ../../../);
  • Pre-setup configs for debugging: both TS files and Jest tests for VSCode & WebStorm;
  • Dev mode with watching for changed files (using tsx);
  • Hide complex config by using official presets for tsconfig & ts-jest. Main motivation — less maintenance if something changes.

@dawsbot
Copy link

dawsbot commented Feb 13, 2024

What is this config supposed to look like if we are using "@swc/jest" instead for the transform? Here is my jest.config.js:

const config = {
  clearMocks: true,
  moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'],
  roots: ['<rootDir>'],
  testEnvironment: 'node',
  collectCoverage: true,
  collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx}'],
  coverageReporters: ['clover'],
  coverageDirectory: '<rootDir>/coverage',
  transform: {
    '^.+\\.(t|j)sx?$': [
      '@swc/jest',
      {
        jsc: {
          target: 'es2021',
        },
      },
    ],
  },
  setupFilesAfterEnv: ['./jestSetup'],
};

module.exports = config;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment