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"