◆ Node.js 18.18 → 18.19 の LTS のマイナーアップデートで動かなくなる
◆ node コマンドに --loader で ts-node/esm を指定するように変更が必要

TypeScript で書かれたコードを ts-node で動かしてるところがあります
自分で書いたコードでもなく特にソースコードはまったく触れてないのですが突然動かなくなりました

何が原因?と探したところ Node.js のバージョンでした
メジャーアップデートではなく LTS のマイナーアップデートで発生するので困りものです
ts-node のほうは同じバージョンです

再現する方法

ts-node を入れます

npm i ts-node

package.json の type を module にします

{
"type": "module",
"dependencies": {
"ts-node": "^10.9.2"
}
}

tsconfig.json で module を esnext にします

{
"ts-node": {
"compilerOptions": {
"module": "esnext"
}
}
}

実行するファイルの中身は console.log だけで大丈夫です

[a.ts]
console.log(1)

ts-node を --esm オプション付きで実行します

npx ts-node --esm a.ts

バージョン

Node.js 18.18 では問題なく動きます

root@b60b0c35998b:/opt# node -v
v18.18.2
root@b60b0c35998b:/opt# npx ts-node --esm a.ts
1

Node.js 18.19 になるとエラーになります

root@edfb2a4126c7:/opt# node -v
v18.19.1
root@edfb2a4126c7:/opt# npx ts-node --esm a.ts
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /opt/a.ts
at new NodeError (node:internal/errors:405:5)
at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:136:11)
at defaultGetFormat (node:internal/modules/esm/get_format:182:36)
at defaultLoad (node:internal/modules/esm/load:101:20)
at nextLoad (node:internal/modules/esm/hooks:864:28)
at load (/opt/node_modules/ts-node/dist/child/child-loader.js:19:122)
at nextLoad (node:internal/modules/esm/hooks:864:28)
at Hooks.load (node:internal/modules/esm/hooks:447:26)
at MessagePort.handleMessage (node:internal/modules/esm/worker:196:24)
at [nodejs.internal.kHybridDispatch] (node:internal/event_target:786:20) {
code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

18.19 では ESM や loader 周りの変更がバックポートされたのでそれの影響のようです
私も少し前に experimental な loader を使ってたら仕様変更で動かなくなったとかあったので ts-node も experimental な機能を使っていたのかもですね

対処

最近の Node.js だと --loader を使うと良いらしいです
ts-node はコマンドではなく loader として使うようになります

root@edfb2a4126c7:/opt# node --loader ts-node/esm a.ts
(node:391) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()`:
--import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("ts-node/esm", pathToFileURL("./"));'
(Use `node --trace-warnings ...` to show where the warning was created)
1

動きましたが experimental-loader を使ってると警告されます
また動かなくなりそうな予感もしますが とりあえず動いてくれればいいのでこのままにします
これは Node.js 20 でも同じでした

警告を消したいなら --no-warnings オプションをつければ消せます

root@edfb2a4126c7:/opt# node --no-warnings=ExperimentalWarning --loader ts-node/esm a.ts
1

TypeScript を使うなら

個人的には既存の動いてたアプリが動かなくなったのでそれが動くようになればそれで十分だったのですが TypeScript を直接書く場合はこれだと不便があるみたいです

上の方法だとコンパイルエラーがあっても詳細がわからないようです
そのために

tsc --noEmit &&

をつけて 事前にチェックしてから ts-node を使うような対処が必要らしいです

詳しくまとまってる StackOverflow の回答がありました
https://stackoverflow.com/a/77993035