くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

TypeScriptのESMでハマる

markdownからhtmlに変換したいなーと思い、
micromarkを使おうとしたら、
ES Modulesでかなりハマったので、その時の備忘録。


はじまり

今まで使っていたらちょっとしたツールのプロジェクトに、
micromarkをインストールしたら、こんなエラーが。。

$ npx ts-node sample.ts
./node_modules/ts-node/dist/index.js:842
            return old(m, filename);
                   ^
Error [ERR_REQUIRE_ESM]: require() of ES Module ./src/node_modules/micromark/index.js from ./src/sample.ts not supported.
Instead change the require of index.js in src/sample.ts to a dynamic import() which is available in all CommonJS modules.
    at Object.require.extensions.<computed> [as .js] (./node_modules/ts-node/dist/index.js:842:20)
    at Object.<anonymous> (./src/sample.ts:3:21)
    at Module.m._compile (./src/node_modules/ts-node/dist/index.js:848:29)
    at Object.require.extensions.<computed> [as .ts] (./src/node_modules/ts-node/dist/index.js:850:16)
    at phase4 (./src/node_modules/ts-node/dist/bin.js:414:16)
    at bootstrap (./src/node_modules/ts-node/dist/bin.js:49:12)
    at main (./src/node_modules/ts-node/dist/bin.js:32:12)
    at Object.<anonymous> (./src/node_modules/ts-node/dist/bin.js:526:5) {
  code: 'ERR_REQUIRE_ESM'
}

たしかに、micromarkのREADME.mdには、

This package is ESM only.

と書かれていたので、これっぽい。

tsconfig.jsonはこんな感じ。

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["esnext", "esnext.asynciterable", "dom"],
    "resolveJsonModule": true,
    "esModuleInterop": true
  }
}

ES ModulesとCommonJS

ES ModulesとCommonJSは、モジュールシステムらしい。

  • ESMはES2015から仕様に入った新しいやつ
  • CommonJSはウェブブラウザ環境外を対象にした古いやつ

ざっくりだと、以下のような書き方。

// ESM
import { micromark } from "micromark";

// CommonJS
const { micromark } = require("micromark");

TypeScriptしか使っていないので、このあたりは気にしたことがなく、
常にimportを使って書いていたので、気にしたことがなかった。。

このあたりは、以下の記事がとても参考になった。

なにがだめだったか

以下の記事でわかりやすくまとめてあるが、
CJSからESMをStatic Importできないことが問題のよう。

TypeScript 4.7 と Native Node.js ESM | by Yosuke Kurami | May, 2022 | Medium

import(require) するファイル import(require) されるファイル Static Import Dynamic Import require
ESM ESM OK OK NG
CJS CJS NG NG OK
ESM CJS OK NG NG
CJS ESM NG OK NG

なので、プロジェクト自体をESM化してみた。

ESM対応

ということで、ESM対応をしてみる。以下の記事が参考になった。
・ Pure ESM package

JavaScript(Node.js)側とTypeScript側でそれぞれやることが異なる。

  • CommonJSプロジェクトをESMに変更する
    • Node.js 14以降をつかう
    • package.jsonに"type": "module"を追加
    • require()の排除
    • importに拡張子を追加
  • TypeScriptからESM形式で出力する
    • TypeScript 4.7以降を使う
    • tsconfig.jsonに"module": "esnext"を追加
    • node --loader ts-node/esm ./my-script.tsを使って呼び出す

また、ESMにすると、__dirnameや__filenameも使えなくなるので注意。

Node.js / TypeScriptのバージョンアップ

それぞれバージョンが決まっているのでアップデートしておく

  • Node.js 14以降をつかう
  • TypeScript 4.7以降を使う

package.jsonの変更

ESMのプロジェクトとして認識してもらえるよう、
"type": "module"を追加。

ついでに、loaderを使って呼び出せるように、
scriptsにもts-esmを追加。

  {
+   "type": "module",
    "scripts": {
      "ts": "npx ts-node",
+     "ts-esm": "node --loader ts-node/esm"
    }
  }

tsconfig.jsonの変更

TypeScriptからESM形式で出力できるよう、
moduleをesnextに変更する。
(ES2020でもOK。ES2015でもよいがimport.metaなど使えない)

  {
    "compilerOptions": {
      "target": "esnext",
-     "module": "commonjs",
+     "module": "esnext",
      "moduleResolution": "node",
      "lib": ["esnext", "esnext.asynciterable", "dom"],
      "resolveJsonModule": true,
      "esModuleInterop": true
    }
  }

ES2022やnode12、node16、nodenextなどの記載もあるが、
現時点ではnightly buildsのみや対応していない状況っぽい。

・TypeScript: TSConfig Reference - Docs on every TSConfig option
・ESM support: soliciting feedback · Issue #1007 · TypeStrong/ts-node

require()の排除

require()はCommonJSの書き方なので、
もし使っていたらimport形式に書き直す。

ただ、importでは.jsonの拡張子を受け付けてくれない。。

import json from "./package.json";
./node_modules/ts-node/dist-raw/node-internal-modules-esm-get_format.js:92
        throw new ERR_UNKNOWN_FILE_EXTENSION(ext, fileURLToPath(url));
              ^
CustomError: ERR_UNKNOWN_FILE_EXTENSION .json ./package.json
...

なので、createRequireでrequireを用意する。

import { createRequire } from "module";
const require = createRequire(import.meta.url);
const json = require("./package.json");

・How to import JSON files in ES modules (Node.js) | Stefan Judis Web Development

"module"が見つからないこともあるので、
@types/nodeも確認する。v12以降じゃないとだめっぽい。

import { createRequire } from "module";

importに拡張子を追加

ESMの場合、import時に拡張子が必須になる。
なので、内部のファイルには、.jsを追加していく。

- import { util_func } from "./my-module/utils";
+ import { util_func } from "./my-module/utils.js";

拡張子がないと、こんな感じでエラーになる。

./node_modules/ts-node/dist-raw/node-internal-modules-esm-resolve.js:366
    throw new ERR_MODULE_NOT_FOUND(
          ^
CustomError: Cannot find module './src/my-module/utils' imported from ./src/sample.ts

拡張子は.tsであったとしても、.jsで書かないといけない。
.mtsの場合は、.mjsで書く。

(おまけ) CJS特有機能の置き換え

CJSにしか無い機能の置き換え例。

// require
import { createRequire } from "module";
const require = createRequire(import.meta.url);
// __filename / __dirname
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

以上!!

たかが、2行のプログラムのために、すごい沼にハマっていった。。(*´ω`*)

import { micromark } from "micromark";
console.log(micromark("## Hello, *world*!"));
// => <h2>Hello, <em>world</em>!</h2>

参考にしたサイトさま