CLOVER🍀

That was when it all began.

TypeScript × Node.jsでECMAScript Modulesを試す

これは、なにをしたくて書いたもの?

少し前に、Node.jsでECMAScript Modulesを試してみました。

Node.jsでECMAScript Modulesを試す - CLOVER🍀

前回はいきなりTypeScriptでやるとわからなくなるだろうから素のJavaScript(Node.js)でECMAScript Modulesを扱ったのですが、今回は
TypeScriptで扱ってみます。

参照しているドキュメントは、TypeScript 5.5時点のものです。

Node.jsのモジュールシステム

少し復習的に。Node.jsで扱えるモジュールシステムにはECMAScript ModulesとCommonJS Modulesの2つがあります。

Modules: Packages | Node.js v20.17.0 Documentation

どういう違いがあるのかはこちらにある程度まとめて書いたのですが、デフォルトはCommonJS Modulesで扱うのでECMAScript Modulesとして
扱うには明示的にファイルの拡張子を.mjsにするか、package.jsontypeフィールドの値をmoduleにするという話でした。
※だいぶ端折ってます

Node.jsでECMAScript Modulesを試す - CLOVER🍀

そして、ECMAScript Modulesを使うとモジュールのインポートをimport文またはimport()式で行うようになり、インポートするモジュールの
指定に拡張子まで含める必要があったりしました。

TypeScriptとECMAScript Modules

TypeScriptでのモジュールの話は、こちらのモジュールリファレンスを見ることになります。

TypeScript: Documentation - Modules - Introduction

モジュールの出力形式は、こちらに書かれています。

Modules - Theory / The module output format

まず注意しておくことは、ホスト(TypeScriptによりトランスパイルされた結果を実行する環境)がどのようなモジュール形式を期待して
いるかということですね。それを踏まえたうえで、TypeScriptによるファイルの出力形式を調整します。

In any project, the first question about modules we need to answer is what kinds of modules the host expects, so TypeScript can set its output format for each file to match.

ポイントになるのはtsconfig.jsonmoduleオプションです。

Intro to the TSConfig Reference / Modules / Module - module

まずはこちらに書かれている箇条書きを見るのがよいでしょう。

Modules - Theory / The module output format

moduleオプションで設定できる値はこちらです。

moduleのオプション 意味
node16 特定の相互運用性と検出ルールにしたがって、ECMAScript ModulesとCommonJS Modulesを並行してサポートするNode.js 16以上のモジュールシステムを反映する
nodenext 現在の値はnode16と同じだが、Node.jsのモジュールシステムが進化するにつれて最新のNode.jsバージョンを反映する動的なターゲットになる
es2015 JavaScriptモジュールにimportおよびexportが導入された最初のECMAScript 2015言語仕様を反映している
es2020 es2015import.metaexport * as ns from "mod"を追加したもの
es2022 es2020にトップレベルのawaitのサポートを追加したもの
esnext 現在はes2022と同じだが、最新のECMAScript仕様と今後の仕様バージョンに含まれることが期待されるモジュール関連のステージ3+提案を反映した動的なターゲット
commonjssystemamdumd 各モジュールシステム向けの出力を生成する。新規プロジェクトには推奨されない

また、ここに載っていない値としてTypeScript 5.4で追加されたpreserveがあります。

Intro to the TSConfig Reference / Modules / Module - module / preserve

これは文単位でexportimportの形式が保持されるオプションのようです。

ところで、Node.jsで使うことを考えるとnode16およびnodenextと、esnextes2022、あるいはcommonjsのどれを指定すればよいか
ですが、これはこちらに書かれています。

Modules - Theory / The module output format

Node.jsで実行する場合は、moduleオプションにesnextcommonjsを指定するのは誤りです。

Node.js’s rules for module format detection and interoperability make it incorrect to specify module as esnext or commonjs for projects that run in Node.js, even if all files emitted by tsc are ESM or CJS, respectively.

Node.jsで実行する場合は、node16およびnodenextのみが正しいmoduleの設定です。

The only correct module settings for projects that intend to run in Node.js are node16 and nodenext.

そして、moduleオプションにnode16またはnodenextを設定すると、暗黙的に他のオプションにも影響を与えます。

Modules - Reference / The module compiler option / node16, nodenext / Implied and enforced options

いずれも暗黙的に、そして強制的に設定されるようです。

  • moduleオプションにnode16を指定した場合
    • moduleResolutionオプションにnode16が設定される
    • targetオプションにes2022が設定される
    • esModuleInteropオプションにtrueが設定される
  • moduleオプションにnodenextを指定した場合
    • moduleResolutionオプションにnodenextが設定される
    • targetオプションにesnextが設定される
    • esModuleInteropオプションにtrueが設定される

esModuleInteropオプションをtrueにすると、TypeScriptを使っている時にECMAScript ModulesからCommonJS Modulesを読み込めるように
なります。

moduleResolutionオプションについては後述します。

モジュール形式の検出

こちらでは、モジュール形式の検出ルールとTypeSccriptによる出力フォーマットのルールが書かれています。

Modules - Theory / The module output format / Module format detection

ざっくり言うと、こんな感じでしょうか。

  • ファイル拡張子が.mtsの場合は、.mjsECMAScript Modules)にトランスパイルされる
  • ファイル拡張子が.ctsの場合は、.cjs(Common Modules)にトランスパイルされる
  • ファイル拡張子が.tsの場合は、.jsにトランスパイルされる
    • この時、もっとも近い親ディレクトリ階層にあるpackage.jsontypeフィールドがmoduleでなければCommonJS Modulesにトランスパイルされる
    • package.jsontypeフィールドがmoduleの場合はECMAScript Modulesにトランスパイルされる

JavaScriptの時の拡張子のルールを知っておくと、およそ予想できる挙動ではないかなと思います。

Module resolution

次は、Module resolutionについて少し触れておきます。

Modules - Theory / Module resolution

tsconfig.jsonmoduleResolutionオプションのことです。モジュールの解決戦略ですね。

これもやっぱり、ホスト側の事情に影響を受けます。

Modules - Theory / Module resolution / Module resolution is host-defined

moduleResolutionオプションで指定できる値はこちらです。

moduleResolutionのオプション 意味
classic TypeScriptの最も古いモジュール解決モードで、modulecommonjsnode16nodenext以外の値に設定されている場合のデフォルト値。さまざまなRequireJS構成に対するベストエフォートでの解決を提供するもの。TypeScript 6.0で廃止される予定であり、新規プロジェクトでは使用しないこと
node10 以前はnodeと呼ばれていた値で、moduleオプションがcommonjsの場合のデフォルト値。Node.js 12より前であれば良いオプションだが、現在のNode.jsはECMAScript Modulesのサポートがあるため良い選択ではない。新規プロジェクトでは使用しないこと
node16 moduleオプションにnode16を指定した時に対応する値。Node.js 12以降はECMAScript ModulesとCommonJS Modulesの両方をサポートしているため、モジュール形式の検出ルールに従い動作が決定する
nodenext 現在はnode16と同一で、moduleオプションにnodenextを指定した場合のデフォルト値。これは新しいNode.jsがモジュール解決機能を追加する度に、それをサポートすることを見据えたモード
bundler Node.js 12でnpmパッケージをインポートするためのいくつかのモジュール解決機能(package.jsonexportsimportsフィールド)が追加され、多くのバンドラーはECMAScript Modulesのより厳格なルールを採用することなくこれらの機能を採用した。このモードはバンドラーをターゲットとする基本アルゴリズムを提供する。デフォルトではpackage.jsonexportsimportsをサポートしているが、これらを無視するように構成することもできる。このモードを使うにはmoduleesnextに設定する必要がある

つまり、Node.jsの場合はnode16またはnodenextを指定すると、package.jsontypeフィールドの指定にしたがってモジュールの
解決方法が変わるということですね。

ドキュメントを見るのはこれくらいにして、あとは試していってみましょう。

環境

今回の環境はこちら。

$ node --version
v20.16.0


$ npm --version
10.8.1

お題

前回、ふつうにJavaScriptで書いたこちらをTypeScriptに置き換えていくことを目指します。

Node.jsでECMAScript Modulesを試す - CLOVER🍀

Node.jsプロジェクトをTypeScript × ECMAScript Modulesとして作成する

Node.jsプロジェクトを作成。

$ npm init -y
Wrote to /path/to/hello-esm/package.json:

{
  "name": "hello-esm",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": ""
}

typeフィールドをmoduleにしてECMAScript Modulesにします。

package.json

{
  "name": "hello-esm",
  "version": "1.0.0",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": ""
}

TypeScript、それからPrettierをインストール。

$ npm i -D typescript
$ npm i -D @types/node@v20
$ npm i -D prettier

tsconfig.jsonはこのようにしました。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

modulenodenextにして、targetmoduleResolutionは暗黙の値を明示的に書いた感じです。

    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",

Prettierの設定。

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 120
}

この時点でのscriptsおよびdevDependencies

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "format": "prettier --write src test"
  },

  ...

  "devDependencies": {
    "@types/node": "^20.14.8",
    "prettier": "^3.3.3",
    "typescript": "^5.5.4"
  }

ソースコードを書いていきます。

拡張子を.mtsにして、明示的にECMAScript Modulesとして書いたもの。トランスパイル後は.mjsになります。

src/calc.mts

export function plus(a: number, b: number): number {
  return a + b;
}

export function minus(a: number, b: number): number {
  return a - b;
}

拡張子は.tsにして、トランスパイル後のモジュールシステムの解決はtsconfig.jsonmoduleResolution経由でpackage.jsontype
任せたもの。今回はtypemoduleなのでECMAScript Modulesとして扱われることになります。

src/calc-resolve-type.ts

export function plusAsResolveModuleType(a: number, b: number): number {
  return a + b;
}

export function minusAsResolveModuleType(a: number, b: number): number {
  return a - b;
}

拡張子を.ctsにして、明示的にCommonJS Modulesとして書いたもの。トランスパイル後は.cjsになります。

src/calc-cjs.cts

export function plusAsCjs(a: number, b: number): number {
  return a + b;
}

export function minusAsCjs(a: number, b: number): number {
  return a - b;
}

JavaScriptで書いていた時と違って、構文上はCommonJSになることはわかりませんけどね。

これらを呼び出すスクリプト

src/run.ts

import { plus, minus } from './calc.mjs';
import { plusAsResolveModuleType, minusAsResolveModuleType } from './calc-resolve-type.js';
import { plusAsCjs, minusAsCjs } from './calc-cjs.cjs';

console.log('use ECMAScript Modules(mts -> mjs) source');
console.log(`  plus(1, 2) = ${plus(1, 2)}`);
console.log(`  minus(5, 2) = ${minus(5, 3)}`);

const calc = await import('./calc.mjs');
console.log('use ECMAScript Modules(mts -> mjs) source as Dynamic import');
console.log(`  plus(1, 2) = ${calc.plus(1, 2)}`);
console.log(`  minus(5, 2) = ${calc.minus(5, 3)}`);

console.log('use ECMAScript Modules(ts type module) source');
console.log(`  plusAsResolveModuleType(1, 2) = ${plusAsResolveModuleType(1, 2)}`);
console.log(`  minusAsResolveModuleType(5, 2) = ${minusAsResolveModuleType(5, 3)}`);

console.log('use CommonJS Modules(cts -> cjs) source');
console.log(`  plusAsCjs(1, 2) = ${plusAsCjs(1, 2)}`);
console.log(`  minusAdCjs(5, 2) = ${minusAsCjs(5, 3)}`);

ポイントは、importする時のいずれも拡張子はトランスパイル後のもの(.mjs.js.cjs)になっていることです。
.mts.ts.ctsではありません。

import { plus, minus } from './calc.mjs';
import { plusAsResolveModuleType, minusAsResolveModuleType } from './calc-resolve-type.js';
import { plusAsCjs, minusAsCjs } from './calc-cjs.cjs';

...

const calc = await import('./calc.mjs');

...

ビルド。

$ npm run build
# tsc --project .

結果。

$ ll dist
合計 24
drwxrwxr-x 2 xxxxx xxxxx 4096  8月 10 22:14 ./
drwxrwxr-x 7 xxxxx xxxxx 4096  8月 10 22:14 ../
-rw-rw-r-- 1 xxxxx xxxxx  236  8月 10 22:14 calc-cjs.cjs
-rw-rw-r-- 1 xxxxx xxxxx  137  8月 10 22:14 calc-resolve-type.js
-rw-rw-r-- 1 xxxxx xxxxx   99  8月 10 22:14 calc.mjs
-rw-rw-r-- 1 xxxxx xxxxx  973  8月 10 22:14 run.js

.mts.mjsに、.cts.cjsになっています。

中身を見てみましょう。

ECMAScript Modulesnにトランスパイルされた2ファイル。

dist/calc.mjs

export function plus(a, b) {
    return a + b;
}
export function minus(a, b) {
    return a - b;
}

こちらはpackage.jsontypemoduleかつtsconfig.jsonmodulenodenextになっているので、ECMAScript Modulesになっています。

dist/calc-resolve-type.js

export function plusAsResolveModuleType(a, b) {
    return a + b;
}
export function minusAsResolveModuleType(a, b) {
    return a - b;
}

CommonJS Modulesとしてトランスパイルされたもの。

dist/calc-cjs.cjs

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.plusAsCjs = plusAsCjs;
exports.minusAsCjs = minusAsCjs;
function plusAsCjs(a, b) {
    return a + b;
}
function minusAsCjs(a, b) {
    return a - b;
}

スクリプト

dist/run.js

import { plus, minus } from './calc.mjs';
import { plusAsResolveModuleType, minusAsResolveModuleType } from './calc-resolve-type.js';
import { plusAsCjs, minusAsCjs } from './calc-cjs.cjs';
console.log('use ECMAScript Modules(mts -> mjs) source');
console.log(`  plus(1, 2) = ${plus(1, 2)}`);
console.log(`  minus(5, 2) = ${minus(5, 3)}`);
const calc = await import('./calc.mjs');
console.log('use ECMAScript Modules(mts -> mjs) source as Dynamic import');
console.log(`  plus(1, 2) = ${calc.plus(1, 2)}`);
console.log(`  minus(5, 2) = ${calc.minus(5, 3)}`);
console.log('use ECMAScript Modules(ts type module) source');
console.log(`  plusAsResolveModuleType(1, 2) = ${plusAsResolveModuleType(1, 2)}`);
console.log(`  minusAsResolveModuleType(5, 2) = ${minusAsResolveModuleType(5, 3)}`);
console.log('use CommonJS Modules(cts -> cjs) source');
console.log(`  plusAsCjs(1, 2) = ${plusAsCjs(1, 2)}`);
console.log(`  minusAdCjs(5, 2) = ${minusAsCjs(5, 3)}`);

実行してみます。

$ node dist/run.js
use ECMAScript Modules(mts -> mjs) source
  plus(1, 2) = 3
  minus(5, 2) = 2
use ECMAScript Modules(mts -> mjs) source as Dynamic import
  plus(1, 2) = 3
  minus(5, 2) = 2
use ECMAScript Modules(ts type module) source
  plusAsResolveModuleType(1, 2) = 3
  minusAsResolveModuleType(5, 2) = 2
use CommonJS Modules(cts -> cjs) source
  plusAsCjs(1, 2) = 3
  minusAdCjs(5, 2) = 2

OKですね。

Jest+ts-jestでテストを書く

最後はテストです。

ふだんはTypeScript+Jestの時はesbuild-jestを使っているのですが、ちょっと大変な感じがしたので今回はオーソドックスにts-jestに
しておきました…。

Jestおよびts-jestのインストール。

$ npm i -D jest @types/jest
$ npm i -D ts-jest

バージョンはこちら。

  "devDependencies": {
    "@types/jest": "^29.5.12",
    "@types/node": "^20.14.8",
    "jest": "^29.7.0",
    "prettier": "^3.3.3",
    "ts-jest": "^29.2.4",
    "typescript": "^5.5.4"
  }

scriptsはこうしておきました。

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "test": "jest",
    "format": "prettier --write src test"
  },

Jestの設定ファイルを生成。

$ npm init jest

jest.config.mjs

const config = {
  collectCoverage: true,
  coverageDirectory: "coverage",
  coverageProvider: "v8",

  testMatch: [
    "**/__tests__/**/*.?([cm])[jt]s?(x)",
    "**/?(*.)+(spec|test).?([cm])[tj]s?(x)"
  ],

  testEnvironment: "node",

  extensionsToTreatAsEsm: [".ts"],
  moduleNameMapper: {
    "^(\\.{1,2}/.*)\\.js$": "$1",
  },
  transform: {
    "^.+\\.m?[tj]sx?$": [
      "ts-jest",
      {
        useESM: true
      }
    ]
  },
};

export default config;

このあたりは

  extensionsToTreatAsEsm: [".ts"],
  moduleNameMapper: {
    "^(\\.{1,2}/.*)\\.js$": "$1",
  },
  transform: {
    "^.+\\.m?[tj]sx?$": [
      "ts-jest",
      {
        useESM: true
      }
    ]
  },

こちらを見て設定。

ESM Support | ts-jest

JestのECMAScript Modulesに関する記述も参考に。

ECMAScript Modules · Jest

まずはテストを素直に書いてみます。

test/calc.test.ts

import { plus, minus } from '../src/calc.mjs';
import { plusAsResolveModuleType, minusAsResolveModuleType } from '../src/calc-resolve-type.js';
import { plusAsCjs, minusAsCjs } from '../src/calc-cjs.cjs';

test('use ECMAScript Modules(mts -> mjs) source', async () => {
  expect(plus(1, 2)).toStrictEqual(3);
  expect(minus(5, 2)).toStrictEqual(3);
});

test('use ECMAScript Modules(mts -> mjs) source as Dynamic import', async () => {
  const calc = await import('../src/calc.mjs');
  expect(calc.plus(1, 2)).toStrictEqual(3);
  expect(calc.minus(5, 2)).toStrictEqual(3);
});

test('use ECMAScript Modules(js type module) source', async () => {
  expect(plusAsResolveModuleType(1, 2)).toStrictEqual(3);
  expect(minusAsResolveModuleType(5, 2)).toStrictEqual(3);
});

test('use CommonJS Modules(cts -> cjs) source', async () => {
  expect(plusAsCjs(1, 2)).toStrictEqual(3);
  expect(minusAsCjs(5, 2)).toStrictEqual(3);
});

テストを実行。

$ npm test

すると、importしようとしているモジュールがないと怒られます。

> hello-esm@1.0.0 test
> jest

 FAIL  test/calc.test.ts
  ● Test suite failed to run

    Cannot find module '../src/calc.mjs' from 'test/calc.test.ts'

    > 1 | import { plus, minus } from '../src/calc.mjs';
        | ^
      2 | import { plusAsResolveModuleType, minusAsResolveModuleType } from '../src/calc-resolve-type.js';
      3 | import { plusAsCjs, minusAsCjs } from '../src/calc-cjs.cjs';
      4 |

      at Resolver._throwModNotFoundError (node_modules/jest-resolve/build/resolver.js:427:11)
      at Object.<anonymous> (test/calc.test.ts:1:1)

どうやら、拡張子を.mts.tsなどにした方がよさそうです。

import { plus, minus } from '../src/calc.mts';
import { plusAsResolveModuleType, minusAsResolveModuleType } from '../src/calc-resolve-type.ts';
import { plusAsCjs, minusAsCjs } from '../src/calc-cjs.cts';

...

  const calc = await import('../src/calc.mts');

再度テストを実行すると、その拡張子は受け付けていないからallowImportingTsExtensionsを有効にしてね、と怒られました。

> hello-esm@1.0.0 test
> jest

 FAIL  test/calc.test.ts
  ● Test suite failed to run

    test/calc.test.ts:1:29 - error TS5097: An import path can only end with a '.mts' extension when 'allowImportingTsExtensions' is enabled.

    1 import { plus, minus } from '../src/calc.mts';
                                  ~~~~~~~~~~~~~~~~~
    test/calc.test.ts:2:67 - error TS5097: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.

    2 import { plusAsResolveModuleType, minusAsResolveModuleType } from '../src/calc-resolve-type.ts';
                                                                        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    test/calc.test.ts:3:39 - error TS5097: An import path can only end with a '.cts' extension when 'allowImportingTsExtensions' is enabled.

    3 import { plusAsCjs, minusAsCjs } from '../src/calc-cjs.cts';
                                            ~~~~~~~~~~~~~~~~~~~~~
    test/calc.test.ts:11:29 - error TS5097: An import path can only end with a '.mts' extension when 'allowImportingTsExtensions' is enabled.

    11   const calc = await import('../src/calc.mts');
                                   ~~~~~~~~~~~~~~~~~

こちらのことですね。allowImportingTsExtensionstrueにすることで、拡張子が.ts.mts.ctsであっても指定できるようになります。

Modules - Theory / Module resolution / Module resolution for bundlers, TypeScript runtimes, and Node.js loaders

もとのtsconfig.jsonに書くのも微妙だったので、tsconfig.jsonを継承したファイルを作成。

tsconfig.test.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "src", "test"
  ]
}

こちらをts-jestに指定するようにしました。

  transform: {
    "^.+\\.m?[tj]sx?$": [
      "ts-jest",
      {
        tsconfig: "tsconfig.test.json",
        useESM: true
      }
    ]
  },

再度テストを実行すると、CommoJS Modulesだけが読み込みに失敗します…。

    Details:

    /path/to/src/calc-cjs.cts:1
    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,jest){export function plusAsCjs(a: number, b: number): number {
                                                                                      ^^^^^^

    SyntaxError: Unexpected token 'export'

      1 | import { plus, minus } from '../src/calc.mts';
      2 | import { plusAsResolveModuleType, minusAsResolveModuleType } from '../src/calc-resolve-type.ts';
    > 3 | import { plusAsCjs, minusAsCjs } from '../src/calc-cjs.cts';
        | ^
      4 |
      5 | test('use ECMAScript Modules(mts -> mjs) source', async () => {
      6 |   expect(plus(1, 2)).toStrictEqual(3);

      at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1505:14)
      at Object.<anonymous> (test/calc.test.ts:3:1)

今回はこちらは諦めることにしました…。

test/calc.test.ts

import { plus, minus } from '../src/calc.mts';
import { plusAsResolveModuleType, minusAsResolveModuleType } from '../src/calc-resolve-type.ts';
// import { plusAsCjs, minusAsCjs } from '../src/calc-cjs.cts';

test('use ECMAScript Modules(mts -> mjs) source', async () => {
  expect(plus(1, 2)).toStrictEqual(3);
  expect(minus(5, 2)).toStrictEqual(3);
});

test('use ECMAScript Modules(mts -> mjs) source as Dynamic import', async () => {
  const calc = await import('../src/calc.mts');
  expect(calc.plus(1, 2)).toStrictEqual(3);
  expect(calc.minus(5, 2)).toStrictEqual(3);
});

test('use ECMAScript Modules(js type module) source', async () => {
  expect(plusAsResolveModuleType(1, 2)).toStrictEqual(3);
  expect(minusAsResolveModuleType(5, 2)).toStrictEqual(3);
});

/*
test('use CommonJS Modules(cts -> cjs) source', async () => {
  expect(plusAsCjs(1, 2)).toStrictEqual(3);
  expect(minusAsCjs(5, 2)).toStrictEqual(3);
});
*/

これでテストがOKになりました。

$ npm test

> hello-esm@1.0.0 test
> jest

 PASS  test/calc.test.ts
  ✓ use ECMAScript Modules(mts -> mjs) source (4 ms)
  ✓ use ECMAScript Modules(mts -> mjs) source as Dynamic import (1 ms)
  ✓ use ECMAScript Modules(js type module) source

----------------------|---------|----------|---------|---------|-------------------
File                  | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------------------|---------|----------|---------|---------|-------------------
All files             |     100 |      100 |     100 |     100 |
 calc-resolve-type.ts |     100 |      100 |     100 |     100 |
 calc.mts             |     100 |      100 |     100 |     100 |
----------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        3.551 s
Ran all test suites.

そういえばこの構成だと、JavaScriptで書いていた時にJestでECMAScript Modulesを使う際にNode.jsにフラグを指定していたのですが、
これは要りませんでしたね。

  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",

    ...
  },

今回はjestコマンドをそのまま実行しました。

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "test": "jest",
    "format": "prettier --write src test"
  },

ところで、設定がいろいろとめんどうな感じだったのでものは試しにとimport時に拡張子を外してみたら

import { plus, minus } from '../src/calc';

怒られますよね、やっぱり…。

 FAIL  test/calc.test.ts
  ● Test suite failed to run

    test/calc.test.ts:1:29 - error TS2307: Cannot find module '../src/calc' or its corresponding type declarations.

    1 import { plus, minus } from '../src/calc';
                                  ~~~~~~~~~~~~~

でも、拡張子が.tsのファイルをimportする場合なら外してもいいみたいです…(省略して怒られたのは.mtsのファイル)。

import { plusAsResolveModuleType, minusAsResolveModuleType } from '../src/calc-resolve-type';

tscコンパイルさせると怒られますけどね…。

おわりに

TypeScriptとNode.jsでECMAScript Modulesを試してみました。

先にNode.js(JavaScript)のみでECMAScript Modulesを試していたので理解が早かったですが、tsconfig.jsonmoduleをどう指定するのが
よいかをちゃんと見れてよかったですね。

あと、Jestでけっこう苦労したのですが…どうなんでしょう、ECMAScript Modulesとの組み合わせでけっこう使われているものなのでしょうか?

ひとまず、せっかく学んだので押さえておこうと思います。

参考)

TypeScriptのmoduleオプションの話、あるいはTypeScript開発者の苦悩、あるいはCJSとESMの話