0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AWS CDK】Cannot find module '../lib/xxx.js' に1時間溶かした話(2026/4時点)

0
Posted at

はじめに

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 はインポートパスを書き換えない設計思想だから」です。

tsciam-stack.tsiam-stack.js にコンパイルします。コンパイル後、Node.js が実行時に参照するのは .js ファイルです。そのため最終的に正しくなるパスをソースにも書いておく、というのが TypeScript のスタンスです。

個人的には「コンパイル後のパスをソースに書く」というのはかなり気持ち悪い感覚があります。ただ仕様としてはこうなっているので、受け入れるしかありません。

2.3 CJS モードでは拡張子なしでも動く

一方、CJS モードでは Node.js の標準動作として「拡張子なしの require」が許容されます。

import { IamStack } from '../lib/iam-stack';  // 拡張子なし

Node.js の require は拡張子なしのパスを渡されると、以下の順で探索します。

  1. iam-stack.js
  2. iam-stack.json
  3. iam-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 固有構文はそのままでは動きません

  • enum
  • namespace
  • パラメータプロパティ(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 commonjsmoduleResolution を明示していない場合、暗黙のデフォルトが 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. 既存プロジェクトで今すぐエラーを消したい
    → 方法1(インポートから .js を消す)。最小の変更で済む。

  2. ts-node で最近エラーが増えてきた、Node.js のバージョンを上げたら動かなくなった
    → 方法4(tsx に乗り換え)。npm i -D tsx して cdk.json を1行書き換えるだけ。

  3. 新規プロジェクトを作る
    → 最初から tsx を使う。2026年時点で ts-node を新規採用する理由はほぼない。

  4. 外部依存を極力減らしたい、CI で確実性を優先したい
    → 方法2(tsc && node)。synth が少し遅くなるが確実に動く。

  5. 最先端を追いたい、将来性重視
    → 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.jsonapp を書き換えるだけで移行できます。コードの変更は不要です
  • 拡張子なしインポートは CJS でも tsx でも動きます。将来 Node.js ネイティブへ移行する際には書き換えが必要になりますが、その時にはプロジェクト全体で整合性を取りやすいです
  • Node.js ネイティブ(node bin/cdk.ts)は魅力的ですが、ESM 縛りで既存コードへの影響が大きいです。CDK の公式テンプレートが追従するまでは様子見でよいでしょう

6.3 チームで運用する場合の注意点

  • ESLint ルールで拡張子の一貫性を強制することをおすすめします。eslint-plugin-importimport/extensions ルールで拡張子の扱いを統一できます
  • CDK プロジェクトのテンプレートは将来 tsx ベースに変わる可能性があるため、新しく cdk init したプロジェクトを流用するときは cdk.jsonapp の中身を確認する癖をつけておきましょう
  • 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分を取り戻せます。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?