# TypeScript の言語サーバーが正しいファイルに TS5097 を出した — .ts を消したらスクリプトが壊れた話

> TypeScript の言語サーバーが正しい import に TS5097 を報告し、.ts を消したら Node のスクリプトが動かなくなりました。tsc は緑のままです。原因は tsconfig の extends の解決失敗でした。

- Source: https://oharu121.com/ja/blog/typescript-ts5097-stale-tsserver-failed-extends-node-type-stripping/
- Published: 2026-09-04T15:32:56+09:00
- Tags: TypeScript, Node.js, 開発ツール, Claude Code

---
## はじめに

エディタが、何か月も動いていた import に赤い波線を出しました。メッセージが名指ししていたのは、すでに有効になっているはずのコンパイラフラグです。そこで警告の対象になっていた `.ts` を消したところ、警告は消えました。**消えたのは、私がそのファイルを壊したからです。** スクリプトはもう起動できなくなり、それでもリポジトリのチェックはすべて通り続けました。原因は、**tsconfig の `extends` を解決できなくなっていた言語サーバー**でした。解決に失敗すると、継承されるはずのコンパイラオプションが、エラーの対象になっていたものも含めてすべて黙って失われます。

本記事では、この偽のエラー、事態を悪化させた「修正」、既存のどのチェックも気づかなかった理由、そして 2 つの症状を同時に説明した診断までを順に整理します。最後に、変更のたびに走るようになった小さなチェックを紹介しますが、応用が利くのは診断のほうです。

## ビルドと食い違った警告

対象は `scripts/glossary-check.ts` で、このブログのリポジトリにある 20 個ほどのメンテナンス用スクリプトのひとつです。VS Code の Problems パネルには、23 行目に対してこう出ていました。

```text
An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
```

これが `TS5097` です。主張は検証できるもので、そして**それは誤りでした**。フラグは有効で、このリポジトリの `tsconfig.json` を経由して `astro/tsconfigs/base.json` から継承されています。

```jsonc title="node_modules/astro/tsconfigs/base.json"
// Allow importing TypeScript files using their native extension (.ts(x)).
"allowImportingTsExtensions": true,
```

コンパイラを直接動かすと、同じ判断になります。

```bash
$ pnpm dlx tsc --noEmit -p tsconfig.json | grep -c "TS5097"
0
```

リポジトリ全体では 16 個のファイルが `.ts` を明示して import しており、その文は 37 個あります。フラグが本当に無効なら、指摘されるのは 1 個ではなく 16 個すべてのはずです。**エディタとコンパイラは同じファイルを読んで、違う結論を出していました。** 本来はここで、声の大きいほうを黙らせるのではなく、理由を疑うべきでした。

## .ts を消して、スクリプトが動かなくなった

私は代わりに拡張子を消しました。

```ts title="scripts/glossary-check.ts"
- import { LOCALES, LOCALE_LABEL } from "../src/i18n/config.ts";
+ import { LOCALES, LOCALE_LABEL } from "../src/i18n/config";
```

波線は消えました。スクリプトも動かなくなりました。

```text
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/…/oharu-tech-blog/src/i18n/config'
imported from /…/oharu-tech-blog/scripts/glossary-check.ts
```

`pnpm check:glossary` は死に、それを連鎖して呼ぶ `pnpm check` も死にました。それを伝えるものは何もありません。エディタは静かでした。エディタから見れば、エラーは修正されたからです。

*Figure — InvertedSignal: 信号は反転していました。コードが正しいうちは騒がしく、壊れた瞬間に静かになりました。*

**覚えておく価値があるのはこの形です。** 診断が騒がしい方向に間違っているのは煩わしいだけです。静かな方向に間違っているのは危険です。そして同じ壊れた言語サーバーが、その両方を順番に起こしました。

> **気を付けて**
>
> **パイプはこれを隠します。** `node scripts/glossary-check.ts | tail` でスクリプトを確認すると、スクリプトが死んでいるのに `EXIT=0` が表示されます。その終了コードは `tail` のものだからです。パイプを通さずに実行するか、`PIPESTATUS` を読んでください。

## 型チェックが緑のままだった理由

このリポジトリのスクリプトは、Node のネイティブな型ストリッピングの上で `node scripts/*.ts` として素のまま実行されます。バンドラーも `tsx` もビルド手順もありません。型ストリッピングは名前のとおりのことをします。型注釈を消し、**import の指定子を含めてそれ以外はそのまま残します**。

一方 TypeScript には `moduleResolution: "Bundler"` が設定されています。バンドラーがそうするように、`"./config"` を `config.ts` として解決します。**ツールは 2 つ、指定子はひとつ、答えは正反対です。**

作業用のディレクトリで 3 つの形をすべて試しました。

| 指定子 | `tsc --noEmit` | `node file.ts` |
| --- | --- | --- |
| `"./lib"` | 通る | `ERR_MODULE_NOT_FOUND` |
| `"./lib.js"` | 通る | `ERR_MODULE_NOT_FOUND` |
| `"./lib.ts"` | 通る | 動く |

`.js` の形は `ts-node` や emit を伴う構成での癖です。そこではコンパイラが `.js` を書き出し、Node が後でそれを見つけます。ここでは何も emit しないので、そのファイルは存在せず、指定子は何も指していません。

**つまり型チェックが緑であることは、スクリプトが起動できる証拠にはなりません。** これはどちらのツールのバグでもありません。`tsc` が答えているのは型についての問いであり、まったく別のリゾルバの下でモジュール指定子が解決するかどうかは、その問いではありません。ただしこの種の破損は、多くのリポジトリが権威として扱っているチェックからは見えない、ということでもあります。

## 原因は extends の解決失敗ひとつ

本当の診断は、2 つ目の警告が出たときに分かりました。今度は `tsconfig.json` 自体に対してです。

```text
File 'astro/tsconfigs/strict' not found.
```

これは 2 つ目のバグではありません。1 つ目の原因です。**`extends` の解決に失敗すると、TypeScript は読めなかったファイルだけを失うのではなく、自分の既定値に戻ります。** 継承されるはずだったオプションもすべて一緒に失われます。`allowImportingTsExtensions` は既定で無効なので、その状態の言語サーバーは正しいファイルに対して `TS5097` を報告するしかありません。症状は 2 つ、原因はひとつです。

ファイル自体は存在し、パッケージも astro@7.2.9 の `exports` マップで `./tsconfigs/*.json` と `./tsconfigs/*` として正しく公開していました。マージ後の設定も、コマンドラインからはきれいに解決します。

```bash
$ pnpm exec tsc --showConfig -p tsconfig.json
strict: true | allowImportingTsExtensions: true | moduleResolution: bundler
```

覚えておく価値があるのはこのコマンドです。マージ済みの設定を表示するので、`tsconfig.json` を読むだけでは決着しない問いに答えてくれます。ファイルが記録しているのは書かれた内容だけであり、書かれた内容は最初から争点ではありませんでした。

変わっていたのは、その下の層です。

```bash
$ ls -ldT node_modules/.pnpm node_modules/astro
Sep 3 21:47:36 2026  node_modules/.pnpm
Sep 3 21:47:36 2026  node_modules/astro -> .pnpm/astro@7.2.9_…_6de70ef1d7752d74750f06aa0e0620e0/…
```

前の晩に pnpm が `node_modules` を書き換えていました。ストアのディレクトリ名には install ごとに変わる peer ハッシュが付き、残っている `astro@*` のディレクトリはひとつだけです。言語サーバーが解決してキャッシュしていたパスは、もう存在しませんでした。**リポジトリの中では何も変わっていません。** 長く動き続けるプロセスの足元が動き、そのプロセスは自信を持って答え続けていました。

*Figure — ExtendsCascade: 1 回の install がキャッシュされたパスを無効にし、その失敗は 4 段階先で、無関係なコンパイラフラグのエラーとして表に出ます。*

私はエージェントの助言を受けて TypeScript の言語サーバーを再起動しました。2 つの警告は同時に消え、それが原因はひとつだったことの裏付けになりました。偶然重なった別々の問題ではなかったということです。

## どちらの信号も拾えないものを見張る仕組み

この時点で、2 つの信号が正反対の方向に信用できないと分かっていました。エディタは自信を持って間違えることがあり、しかも報告するのは直前に編集したファイルだけです。だから import 元を壊しても警告は出ません。`tsc` は型については正しく、スクリプトが起動できるかどうかについては構造的に何も見ていません。

私は、自動で走るものが欲しいと頼みました。エージェントはターンの終わりに動くフックを提案し、私はファイルを編集するたびに走らせる案よりそちらを選びました。今回の発端になった破損は誰も触っていないファイルにあり、編集ごとのチェックは次の編集で直る途中の状態まで報告してしまうからです。

設計に取りかかる前に、測っておくべき事実が 3 つありました。どれが外れても設計そのものが崩れるからです。エージェントは使い捨てのフックを書き、ログファイルに向けて、実際に何が届くのかを確かめました。

| 経路 | エージェントに届くか |
| --- | --- |
| exit 0、素の標準出力 | **届かない。** 目印は一度も届きませんでした |
| exit 0、JSON の `hookSpecificOutput.additionalContext` | 届く |
| exit 2、標準エラー出力 | ブロッキングエラーとして届く |

**結果を `echo` するだけのフックは、動いて、正常終了して、誰にも伝えません。** フックが動いているのに効いていないように見えるとき、最初に確かめるべきはこれです。このチェック自体は、ブロックするのではなく助言に留めてあります。このイベントで異常終了するとターンが終われなくなり、直せないエラーがあると永久に回り続けるからです。

チェックの後半は、Node が拒否する指定子を探します。`scripts/` をそのまま grep するのではなく、そこから import グラフをたどります。この違いは見た目だけのものではありません。これらのスクリプトは `src/` に対して 17 本の依存を持ち、Node はそこから読み込むものもすべて読み込むので、同じ規則がそれらのファイルにも及びます。一方 `src/i18n/article-locale.ts` は `"./config"` を拡張子なしで import していますが、まったく問題ありません。`scripts/` の下からそこへ到達するものがなく、到達するすべてのファイルは Vite が解決するからです。

*Figure — ImportGraphWalk: src/ にある 2 つの拡張子なしの import は、判定が正反対になります。分けているのは到達可能性だけで、それは平坦な grep には見えません。*

`scripts/` を grep するやり方では、`src/` の中の本物の破損を見逃すか、正しいファイルを壊れていると報告するかのどちらかになります。**判定を分けているのは到達可能性であり**、その計算は安上がりです。

## 検証

このチェックは 0.86 秒で終わります。`tsc --noEmit` 単体が 1.3 秒、`astro check` が 11.4 秒なので、後者は入れませんでした。**ツリーがきれいなときは何も出力しません。** 静かなターンは静かなままです。

発端になったバグに向けると、エディタが一度も言わなかったことを言います。

```text
1 unresolvable import(s) reachable from scripts/ — invisible to tsc, fatal at runtime:

scripts/glossary-check.ts(23): import "../src/i18n/config" has no extension —
Node cannot resolve it (tsc accepts it; the script will not run).
```

本物の型エラーを与えた場合は、編集した行のエラーに加えて、**編集がまったく触れていない 24 行離れた箇所の `TS2367`** も報告しました。この 2 つ目こそ、ファイル単位のエディタ診断には出せないものです。

マージ前のコードレビューは 3 件の欠陥を見つけ、そのうち最悪のものは失敗時の経路にありました。`existsSync` はディレクトリに対して `true` を返すため、そこに `readFileSync` を掛けると `EISDIR` を投げます。それを受け止めるものがありませんでした。チェック全体が何も出力せずに終了し、`tsc` は一度も走りません。**成功の合図が沈黙であるチェックにとって、この失敗の仕方は成功と区別がつきません。** 残る 2 件は誤検出で、コメントアウトされた import や折り返された `import type` を致命的なものとして読んでいました。

> **あちゃー**
>
> **最初の修正は不十分で、それを見つけたのは再テストでした。** コメントを空白化したことで 5 件の誤検出のうち 4 件は消えましたが、サンプルコードを収めたテンプレートリテラルが壊れた import として報告され続けていました。今はバックティックのリテラルも空白化しています。静的な import の指定子がテンプレートリテラルになることはないので、これは安全です。

## まとめ

- **`extends` の解決に失敗すると、継承されるオプションがすべて失われます。** そのとき出るエラーが指すのはフラグであって、解決できなかったパスではありません。まず `extends` の失敗を診断し、残りはその下流として扱ってください。
- **言語サーバーを陳腐化させるのは `pnpm install` です。** ストアのディレクトリ名には install ごとに変わる peer ハッシュが付くため、キャッシュされた解決済みのパスが存在しなくなります。install の後は言語サーバーを再起動し、波線を黙らせるためにコードを編集する前に、まずこれを疑ってください。
- **エディタと `tsc` が食い違ったら、`tsc` が勝ちます。** 決着は `tsc --showConfig` でつけます。ファイルではなくマージ後の設定が表示されます。
- **型チェックが通ることは、スクリプトが動くことを意味しません。** `moduleResolution: "Bundler"` と Node のネイティブな型ストリッピングの組み合わせでは、拡張子なしの相対指定子は型チェックを通り、起動時に失敗します。`"./lib"` も `"./lib.js"` も失敗し、通るのは本物の拡張子だけです。
- **スクリプトの確認をパイプ越しにしてはいけません。** `node script.ts | tail` が返すのは `tail` の終了コードです。

## 参考リンク

- [TypeScript: allowImportingTsExtensions と、それに伴う noEmit の要件](https://www.typescriptlang.org/tsconfig/#allowImportingTsExtensions)
- [TypeScript: moduleResolution、拡張子の扱いで Bundler が Node16 とどう違うか](https://www.typescriptlang.org/tsconfig/#moduleResolution)
- [Node.js: TypeScript をそのまま実行する仕組みと、型ストリッピングが指定子を書き換えないという規則](https://nodejs.org/api/typescript.html)
