はじめに
CDK で npx cdk synth を実行したら Cannot find module '../lib/xxx-stack.js' というエラーが出た。ファイルは .ts で存在しているのに、なぜ .js を探しに行くのか——そんな疑問を持ったことはないでしょうか。
自分はこれで1時間ほど溶かしました。。。
エラーの原因は ts-node(CJSモード)が import '...js' を文字通り受け取って、ディスク上の .js ファイルを探しに行くことです。仕組みを知ってしまえば「なるほど」なのですが、知らないと完全に詰まります。
個人的な見解ですが、2026年現在の最適解は「tsx に乗り換えて、インポートは拡張子なしに統一」 することだと思っています。
(ts-node は実質メンテナンスモードに入っており、新規プロジェクトで選ぶ理由がもうないため)
本記事では「なぜこのエラーが起きるのか」の仕組みから、各解決策の比較、Node.js 24 のネイティブ TypeScript サポートや TypeScript 6.0 のインパクトまで、2026年4月時点の情報をまとめます。
1. 症状:CDK が「存在しないはずの .js ファイル」を探しに行く
CDK プロジェクトで以下のようにimport文を書いたとします。
// bin/cdk.ts
import { IamStack } from '../lib/iam-stack.js';
// ↑ .js 拡張子に注目
そして npx cdk synth を実行すると、こう怒られます。
Error: Cannot find module '../lib/iam-stack.js'
Require stack:
- /path/to/cdk/bin/cdk.ts
しかしファイルを確認すると lib/iam-stack.ts はちゃんと存在しています。
.js なんてファイルはそもそも生成していないのに、なんでや!?
「.ts で書いてるのに .js を探しに来るのおかしくない?」——この疑問がこの記事の出発点です。
2. 前提:なぜ .js を書くスタイルが存在するのか
エラー原因を理解するには、TypeScript のモジュール解決の仕様を押さえる必要があります。
2.1 NodeNext と「CJS か ESM か」の判定
tsconfig.json で "module": "NodeNext" を指定すると、TypeScript は Node.js と同じルールで「そのファイルが CJS か ESM か」を判定します。
-
package.jsonに"type": "module"がある → ESM -
"type": "module"がない(デフォルト) → CJS -
.mts/.cts拡張子で個別に上書き可能
CDK プロジェクトのデフォルトテンプレートには "type": "module" が含まれていないため、デフォルトでは CJS として扱われます。
2.2 NodeNext では .js 拡張子付きインポートが正式
TypeScript 公式仕様では、module: "NodeNext" の場合、インポートパスにはコンパイル後の拡張子 .js を書くのが正しいです(TypeScript: Modules - Reference (NodeNext))。
// TypeScript 公式推奨の書き方(NodeNext)
import { IamStack } from '../lib/iam-stack.js';
「ソースコードは .ts なのに、なぜ .js と書くのか?」という疑問が生じたと思います。
答えは「TypeScript はインポートパスを書き換えない設計思想だから」です。
tsc は iam-stack.ts を iam-stack.js にコンパイルします。コンパイル後、Node.js が実行時に参照するのは .js ファイルです。そのため最終的に正しくなるパスをソースにも書いておく、というのが TypeScript のスタンスです。
個人的には「コンパイル後のパスをソースに書く」というのはかなり気持ち悪い感覚があります。ただ仕様としてはこうなっているので、受け入れるしかありません。
2.3 CJS モードでは拡張子なしでも動く
一方、CJS モードでは Node.js の標準動作として「拡張子なしの require」が許容されます。
import { IamStack } from '../lib/iam-stack'; // 拡張子なし
Node.js の require は拡張子なしのパスを渡されると、以下の順で探索します。
iam-stack.jsiam-stack.jsoniam-stack.node
ts-node を使っている場合はさらに .ts や .tsx も探索対象に加わります(後述)。このため、CJS + 拡張子なしという組み合わせは「TypeScript 公式仕様ではないが、実質的に広く動く」状態です。
3. 根本原因:ts-node(CJSモード)の挙動
ここまでが前提で、いよいよ本題です。
ts-node は「ビルドせずに .ts をそのまま実行する」ツールです。CJS モード(デフォルト)では内部的に require を使ってモジュールを読み込んでいます。
挙動を整理するとこうなります。
| インポート文 | ts-node の動作 | 結果 |
|---|---|---|
'../lib/iam-stack' |
require の標準解決で .ts → .tsx → .js の順に探索 |
.ts を見つけて実行 ✅ |
'../lib/iam-stack.js' |
.js ファイルをピンポイントで探しに行く |
ディスクに .js がないのでエラー ❌ |
身近な例で言うなら、受付でこう尋ねるようなものです。
- 「田中さんいますか?」
- 受付が「田中太郎さんですね」と案内してくれる(拡張子なし)
- 「田中太郎ジュニアさんいますか?」
- 「そんな人いません」と断られる(
.js指定)
- 「そんな人いません」と断られる(
つまり、 ソースコードに書いた .js は ts-node にとって「実在しないファイル名」 です。
ts-node は TypeScript 公式の「コンパイル後のパスを書く」思想に追従しきれていません。
3.1 補足:CDK デフォルトテンプレートの --prefer-ts-exts
ちなみに CDK のデフォルトテンプレートの cdk.json はこうなっている。
{
"app": "npx ts-node --prefer-ts-exts bin/cdk.ts"
}
--prefer-ts-exts は「同名の .ts と .js が両方あるとき .ts を優先する」フラグで、これが付いていないと過去のビルド成果物である .js を先に読んでハマる問題がありました(aws/aws-cdk#7475 で議論された経緯があります)。
ただし --prefer-ts-exts は「存在しない .js を探しに行く挙動そのもの」は変えてくれません。拡張子付きインポートを書いている場合は依然として同じエラーに遭遇します。
4. 解決策:4つの選択肢
対処法は大きく4つあります。
方法1:インポートから .js を消す(最小工数)
ts-node を使い続けるなら、拡張子なしに統一するのが最も手軽です。
// Before
import { IamStack } from '../lib/iam-stack.js';
// After
import { IamStack } from '../lib/iam-stack';
CJS モードでは require の標準解決が効くので問題なく動きます。
プロジェクト内で拡張子ありとなしを混在させないこと。
混在しているとレビューが煩雑になりますし、将来 tsx や Node.js ネイティブに移行するときにも地味に障害になります。
方法2:tsc ビルド → node 実行に切り替える
ts-node を使わず、先にビルドしてから実行する方式に変更します。
// cdk.json
{
"app": "npx tsc && node dist/bin/cdk.js"
}
この方式なら .js 拡張子付きインポートでも問題なく動きます(本来の TypeScript 公式仕様通りの動作です)。
トレードオフ:ビルドステップが入る分、synth 時間が数秒〜十数秒伸びます。ローカルでの試行錯誤が少し面倒になりますが、CI/CD パイプラインでは確実性が高く、ts-node 特有の互換性問題を完全に回避できるので、プロダクション用途ではむしろこちらを好む人もいます。
方法3:ts-node の experimentalResolver を有効にする
ts-node には .js → .ts のリマッピングを行う実験的機能が用意されています(ts-node Options - experimentalResolver)。
// tsconfig.json
{
"ts-node": {
"experimentalResolver": true
}
}
これを有効にすると .js 拡張子付きインポートでも ts-node が .ts ファイルを探してくれます(TypeStrong/ts-node#1781)。
注意点:「experimental」の名の通り、将来の挙動変更リスクがあります。そもそも ts-node 自体がメンテナンス停滞気味(後述)なので、この選択肢を積極的に選ぶ理由は薄いです。
方法4:tsx に乗り換える(2026年の推奨解)
tsx は ts-node の事実上の後継ツールで、ESM / CJS の差異を吸収してくれます。.js 拡張子付きでも拡張子なしでも動きます(tsx - TypeScript Execute)。
npm install -D tsx
// cdk.json
{
"app": "npx tsx bin/cdk.ts"
}
これだけで完了です。コード側の変更は不要で、cdk.json の1行と package.json の依存追加だけで移行できます。
内部的には tsx は esbuild を使ってトランスパイルしているため、ts-node より起動も高速です(環境によっては synth 時間が数倍速くなることもあります)。
4.1 比較表
| 実行方式 | 拡張子なし |
.js 拡張子付き |
速度 | 将来性 |
|---|---|---|---|---|
| ts-node(デフォルト) | ✅ 動く | ❌ エラー | 遅い | △ |
ts-node + experimentalResolver
|
✅ 動く | ✅ 動く | 遅い | △ |
| tsx | ✅ 動く | ✅ 動く | 速い | ○ |
| tsc + node | ✅ 動く | ✅ 動く | 中 | ○ |
既存プロジェクトで「今すぐ直したい」なら方法1(拡張子を消す)が最小工数です。新規や長期運用を考えるなら方法4(tsx)が2026年時点のベストチョイスです。
5. 2026年のアップデート:周辺ツールの地殻変動
この問題を取り巻く環境は、ここ1〜2年で大きく変わりました。
5.1 Node.js:ネイティブ TypeScript サポートの安定化
Node.js は v22.6.0 で --experimental-strip-types を実験的に導入し、v22.18.0 以降ではデフォルト有効になっています。
そして Node.js 24 系では ExperimentalWarning も消え、.ts ファイルをフラグなしで直接実行できます(Node.js - Running TypeScript Natively)。
# Node.js 24 以降、フラグ不要で .ts を直接実行可能
node bin/cdk.ts
ただし注意点があります。
Node.js のネイティブサポートは「型ストリッピング(Type Stripping)」という方式を採用しています。
これは型注釈を空白に置換するだけの軽量処理で、tsc のようなフル変換ではありません。
そのため以下の TypeScript 固有構文はそのままでは動きません。
enumnamespace- パラメータプロパティ(
constructor(public readonly foo: string)のpublic/readonly) - 旧式の
moduleキーワード
これらを使いたい場合は --experimental-transform-types フラグを追加で有効にする必要があります。
CDK コードでは通常 enum などはほぼ使わないので、実用上はフラグなしで動くケースが大半です。
もう一つ重要な制約として、Node.js ネイティブ実行では ESM として扱われます。
つまり "type": "module" 相当の世界になるため、拡張子なしの相対インポート('../lib/iam-stack')は通りません。
.js 拡張子付き('../lib/iam-stack.js')か .ts 拡張子付き(後述の rewriteRelativeImportExtensions 利用時)にする必要があります。
「既存の CDK プロジェクトを Node.js ネイティブで動かす」には、インポート文の全面書き換えが必要になるため、移行コストはそれなりに高いです。
5.2 TypeScript 6.0:モジュール解決の整理
TypeScript 6.0(2026年3月リリース)では、モジュール解決周りで大きな変更がありました。
-
--moduleResolution classicが削除 -
--moduleResolution node(node10)が非推奨(deprecation warning が出る) -
--module commonjsでmoduleResolutionを明示していない場合、暗黙のデフォルトがbundlerに変わった -
--target es5が非推奨 -
strictがデフォルトtrue -
typesがデフォルト空配列(@types/nodeなどを明示指定する必要がある)
NodeNext は引き続き推奨です。
Node.js をターゲットにするプロジェクトでは NodeNext、バンドラー経由のプロジェクトでは bundler を使うという方針で整理されました。
なお TypeScript 6.0 は「JavaScript ベースで書かれた最後のバージョン」と公式にアナウンスされており(Announcing TypeScript 6.0)、次期 TypeScript 7.0 は Go でネイティブ実装されたコンパイラになります。
ビルドが大幅に高速化する予定で、6.0 はその移行のための橋渡し版という位置づけです。
5.3 ts-node の現状
ts-node は v10.9.2(2024年1月リリース)以降、安定版の新リリースが出ていません。
v11.0.0-beta は存在しますが beta のまま長期間置かれています。
Node.js の ESM ローダー API は比較的頻繁に変更が入っていますが、ts-node はそれに追従しきれておらず、新しい Node.js バージョンとの互換性問題が増えています。
特に Node.js 20 以降では ESM 関連のエッジケースで挙動が怪しくなるケースが報告されています。
代替ツールの選択肢は以下のとおりです。
| ツール | 特徴 |
|---|---|
| tsx | ts-node のドロップイン代替。ゼロコンフィグ、esbuild ベースで高速 |
| Node.js ネイティブ | v22.18+ / v24+ で .ts を直接実行。外部依存なし。ただしESM縛り |
| Bun | ランタイムごと置き換え。最速だが Node.js 互換性に注意点あり |
| swc-node | SWC ベース。高速だが tsx ほど広く使われていない |
新規プロジェクトなら tsx または Node.js ネイティブの2択という状況です。
5.4 rewriteRelativeImportExtensions(TypeScript 5.7+)
TypeScript 5.7 で追加された rewriteRelativeImportExtensions オプションにより、.ts 拡張子でインポートを書けるようになりました(TypeScript: Modules - Theory)。
// tsconfig.json
{
"compilerOptions": {
"rewriteRelativeImportExtensions": true
}
}
// 以下のように書くと、tsc が自動的に .js に書き換えてコンパイルしてくれる
import { IamStack } from '../lib/iam-stack.ts';
「ソースコードに .js を書くのは気持ち悪い」という長年の不満に対する TypeScript 公式の回答です。
個人的にはこれが一番しっくりくる書き方で、IDE のジャンプや検索との相性も良くなります。
ただし .d.ts の書き換えに未対応などエッジケースがまだ残っているため、大規模プロジェクトへの採用は少し様子見したほうが無難です。
6. で、結局どうすればいいのか
選択肢が多くて迷う人向けに、判断フローチャートをまとめます。
6.1 シチュエーション別の推奨
-
既存プロジェクトで今すぐエラーを消したい
→ 方法1(インポートから.jsを消す)。最小の変更で済む。 -
ts-node で最近エラーが増えてきた、Node.js のバージョンを上げたら動かなくなった
→ 方法4(tsx に乗り換え)。npm i -D tsxしてcdk.jsonを1行書き換えるだけ。 -
新規プロジェクトを作る
→ 最初から tsx を使う。2026年時点で ts-node を新規採用する理由はほぼない。 -
外部依存を極力減らしたい、CI で確実性を優先したい
→ 方法2(tsc && node)。synthが少し遅くなるが確実に動く。 -
最先端を追いたい、将来性重視
→ Node.js 24 +"type": "module"+.js拡張子付きインポート、またはrewriteRelativeImportExtensions+.ts拡張子。ただし既存コードからの移行コストは高い。
6.2 デファクトスタンダードとしての推奨
2026年4月時点で個人的に最も無難だと考える構成は以下です。
// cdk.json
{
"app": "npx tsx bin/cdk.ts"
}
// すべてのインポートを拡張子なしで統一
import { IamStack } from '../lib/iam-stack';
理由を整理しておきます。
- ts-node は実質メンテナンスモードです。Node.js の進化に追従できておらず、今後さらに互換性問題が増える可能性が高いです
- tsx はドロップイン代替で、
cdk.jsonのappを書き換えるだけで移行できます。コードの変更は不要です - 拡張子なしインポートは CJS でも tsx でも動きます。将来 Node.js ネイティブへ移行する際には書き換えが必要になりますが、その時にはプロジェクト全体で整合性を取りやすいです
- Node.js ネイティブ(
node bin/cdk.ts)は魅力的ですが、ESM 縛りで既存コードへの影響が大きいです。CDK の公式テンプレートが追従するまでは様子見でよいでしょう
6.3 チームで運用する場合の注意点
-
ESLint ルールで拡張子の一貫性を強制することをおすすめします。
eslint-plugin-importのimport/extensionsルールで拡張子の扱いを統一できます - CDK プロジェクトのテンプレートは将来 tsx ベースに変わる可能性があるため、新しく
cdk initしたプロジェクトを流用するときはcdk.jsonのappの中身を確認する癖をつけておきましょう - CI では
tsc --noEmitで型チェックを必ず入れてください。tsx は型チェックをしないので、エディタまたは CI での型チェックが必須です
7. まとめ
- CDK の
Cannot find module '../lib/xxx.js'エラーの正体は「ts-node が.js拡張子付きインポートを文字通り解釈して、存在しない.jsファイルを探しに行く」ことです - TypeScript 公式の
NodeNext仕様では.jsを書くのが正しいですが、ts-node の CJS モードはその仕様に追従していません - 解決策は4つあります。2026年時点では tsx に乗り換えて拡張子なしに統一するのが最も実用的です
- ts-node は実質メンテナンスモードに入っており、新規採用する理由がなくなりつつあります
- Node.js 24 以降は
.tsをネイティブ実行できますが、ESM 縛りになるため既存 CDK プロジェクトからの移行には追加作業が必要です - TypeScript 6.0 は非推奨オプション整理の過渡期リリースで、次期 7.0 は Go ネイティブで高速化される予定です。動向を追う価値があります
TypeScript 周りのツールチェーンは変化が激しく、正直追いかけるのが大変です。ただ最低限「自分の CDK プロジェクトが何で動いているのか(ts-node / tsx / tsc+node / Node.js ネイティブ)」を把握しておくだけで、この手のエラーに遭遇したときの対処速度が格段に上がります。冒頭の30分を取り戻せます。