created: 2023-11-14T03:53:34.254Z

ts-node をつかって js から ts を import しようとしてよく発生するエラー

まず、なにも考えずに以前のように --require ts-node/register をつけて node のプロセスを起動するとこのようなエラーが発生する。

$ ./bin/cli.js
.../cli.js:2
import { CommandLineInterface } from "./src/cli.ts";
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at internalCompileFunction (node:internal/vm:74:18)
...

これの原因は

  • node はデフォルトだと commonjs 形式のファイルしか実行しない
  • tsconfig.json の設定が module: "commonjs" となってなければ tsc は ESM 形式のコードを出力する
  • ts-node が tsconfig.json の設定にそってトランスパイルした ESM の形式のファイルを node は読めなくてエラーになる

というもの。

なんとかする

node はデフォルトだと commonjs 形式のファイルしか実行しないのだが、package.json の設定を変更することで ESM 形式のファイルを実行できるようになる。具体的にはこうすれば ESM を実行する指定になる。

  "type": "module",

次のエラー

しかしこの設定をすると次のエラーが発生する。

$ ./cli.js
node:internal/errors:491
    ErrorCaptureStackTrace(err);
    ^

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for ./src/cli.ts
...

これの原因は

  • ESM は本来の仕様だとモジュールを読み込むとき、拡張子もチェックする
  • ESM で、未知の拡張子を読み込むためには module というモジュールの Hook API を操作する必要がある

というもの。

ESM は拡張子が必須

ドキュメントに書いてあった。(そうなんだ?)

A file extension must be provided when using the import keyword to resolve relative or absolute specifiers. Directory indexes (e.g. './startup/index.js') must also be fully specified.

Hook API

require を呼ぶときには commonjs では require.extenstions[".js"] という仕組みがあったが、ESM には Hooks API というものが用意されていて、import が発生した場合の挙動に処理を挟み込むことができるようになっている。

「処理を挟む」とはたとえば、読み込まれたときにトランスパイルをするようなことができる。ts-node の esm.ts のコードを読むと、 node の module というモジュールの機能である hook を使っていることがわかる。

解決策

理屈はともかくとして、どうしたらエラーが出なくなるか。

こうすればよかった

$ cat ./cli.js
#!/usr/bin/env node --loader ts-node/esm --no-warnings=ExperimentalWarning
import { CommandLineInterface } from "./src/cli.ts";
const cli = CommandLineInterface.initialize();
cli.run(process.argv);

2023 年 11 月だと、この実装で問題なく実行できるようになる。

$ chmod +x ./cli.js
$ ./cli.js
[
  '/app/.nodebrew/node/v18.13.0/bin/node',
  '/app/cli.js'
]

--loader ts-node/esm をつけて実行すると以下の警告が出る。

(node:13093) ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time

Experimental であることは承知の上で実行しているので、 --no-warnings=ExperimentalWarning はこれを抑制するためにつけている。

所感

なんとなく仕組みがわかったようなわからないような。

このあたりは実装がどんどん変化していて、issue で話されていることがすぐに陳腐化してしまうせいかあまりスッキリはしなかった。しかし後者の ERR_UNKNOWN_FILE_EXTENSION エラーについては GitHub の issue で説明しているコメントがわかりやすかったのでこれで納得することにした。

node forces a different style of registration to hook into the ESM loader. So if you choose to use node's ESM loader, you'll also need to use this new style of registration with ts-node. If we're not registered correctly, then we have no way of resolving and compiling your .ts files.

Typically you should have your tsconfig set to "module": "CommonJS" so that imports are compiled into commonjs calls. Then you don't need to mess with node's ESM loader. This is probably what you want: use "module": "CommonJS"

単体テストの考え方/使い方
[ad] 単体テストの考え方/使い方
Vladimir Khorikov, 須田智之 (単行本(ソフトカバー))