# TypeScript 語言伺服器對正確的檔案報出 TS5097 — 刪掉 .ts 之後指令碼就再也跑不起來

> TypeScript 語言伺服器對正確的 import 報出 TS5097，刪掉 .ts 之後 Node 指令碼就壞了，但 tsc 依然全綠。真正的原因是 tsconfig 的 extends 解析失敗。

- Source: https://oharu121.com/zh-tw/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 底下畫上了紅色波浪線。訊息點名的那個編譯器 flag，我相當確定早就是開著的，於是我把它抱怨的 `.ts` 副檔名刪掉，警告就消失了。**警告會消失，是因為我把這個檔案弄壞了**：指令碼再也無法啟動，而 repo 裡的每一項檢查依然全部通過。真正的原因是**一個無法解析 tsconfig `extends` 的 TypeScript 語言伺服器**，而解析失敗會悄悄丟掉所有繼承而來的編譯器選項，包含這個錯誤訊息所講的那一個。

本文會依序走過這個假錯誤、那個讓情況更糟的「修正」、既有關卡為什麼都沒有察覺，以及最後同時解釋兩個症狀的診斷。文章結尾會介紹現在每次變更後都會執行的小型檢查，不過真正能帶走的是診斷的部分。

## 與建置結果不一致的警告

出問題的檔案是 `scripts/glossary-check.ts`，是這個部落格 repo 裡約二十個維護用指令碼的其中一個。VS Code 的 Problems 面板對第 23 行顯示：

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

這就是 `TS5097`。這個說法是可以查證的，而**它是錯的**。flag 本來就是開的，透過這個 repo 自己的 `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
```

整個 repo 裡有 16 個檔案是明確帶著 `.ts` 副檔名 import 的，共 37 個 import 陳述式。如果這個 flag 真的關著，被指出來的應該是全部 16 個，而不是 1 個。**編輯器和編譯器讀的是同一個檔案，卻得出不同的結論。** 那一刻該做的是追問為什麼，而不是去安撫比較大聲的那一邊。

## 刪掉 .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`。

## 為什麼型別檢查依然是綠的

這個 repo 的指令碼是以 `node scripts/*.ts` 直接執行的，跑在 Node 原生的型別剝離上。沒有打包工具，沒有 `tsx`，也沒有建置步驟。型別剝離做的事就如其名：它抹掉型別註記，其餘原封不動，**包含 import 的指定字串**。

另一邊，TypeScript 設定的是 `moduleResolution: "Bundler"`，它會像打包工具那樣，愉快地把 `"./config"` 解析成 `config.ts`。**兩個工具，一個指定字串，答案完全相反。**

我在一個暫存目錄裡把三種寫法都試過：

| 指定字串 | `tsc --noEmit` | `node file.ts` |
| --- | --- | --- |
| `"./lib"` | 通過 | `ERR_MODULE_NOT_FOUND` |
| `"./lib.js"` | 通過 | `ERR_MODULE_NOT_FOUND` |
| `"./lib.ts"` | 通過 | 正常執行 |

`.js` 這種寫法是從 `ts-node` 和會產出檔案的設定帶過來的反射動作，在那些情境裡編譯器會寫出 `.js` 讓 Node 之後找得到。這裡不會產出任何檔案，所以那個檔案並不存在，指定字串指向的是空的。

**所以型別檢查是綠的，並不能證明指令碼跑得起來。** 這不是任何一邊的 bug。`tsc` 回答的是關於型別的問題，而一個模組指定字串在另一套完全不同的解析器底下能不能解析，並不是那個問題。但這確實表示：這一類的損壞，對多數 repo 視為權威的那道關卡而言是看不見的。

## 原因是一個解析失敗的 extends

真正的診斷是在第二個警告出現時才浮現的，這次針對的是 `tsconfig.json` 本身：

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

這不是第二個 bug，而是第一個的原因。**當 `extends` 解析失敗時，TypeScript 失去的不只是那個讀不到的檔案，而是整個退回自己的預設值**，所有原本會繼承的選項也一起消失。`allowImportingTsExtensions` 預設是關的，所以處在那個狀態的語言伺服器，必然會對正確的檔案報出 `TS5097`。兩個症狀，一個根源。

檔案本身存在，套件也正確地在 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`。它的 store 目錄名稱帶有每次 install 都會變的 peer hash，而現在只剩下一個 `astro@*` 目錄，所以語言伺服器先前解析並快取起來的那條路徑已經不存在了。**repo 裡什麼都沒有改變。** 移動的是一個長時間執行的行程腳下的地面，而那個行程仍然充滿自信地繼續作答。

*Figure — ExtendsCascade: 一次 install 讓快取起來的路徑失效，而這個失敗在四步之外，才以一個無關的編譯器 flag 錯誤浮上檯面。*

我依照代理的建議重啟了 TypeScript 語言伺服器。兩個警告同時消失，這也證實了原因只有一個，而不是兩個湊巧同時發生的問題。

## 為兩種訊號都涵蓋不到的部分加上關卡

到這裡，兩種訊號都已經被證明在相反的方向上不可信。編輯器可能非常有自信地判斷錯誤，而且它只會回報剛剛編輯過的那個檔案，所以把上游的 import 弄壞根本不會產生任何診斷。`tsc` 在型別上是正確的，但對於指令碼能不能啟動，它在結構上完全看不到。

我要求一個會自己跑起來的東西。代理提出一個在每一輪結束時觸發的 hook，而我選了它，而不是每次編輯檔案後就跑一次：這次的起因就在一個沒有人碰過的檔案裡，而且逐次編輯的檢查還會回報那些下一次編輯就會修好的中間狀態。

在這個設計值得動手之前，有三件事必須先量過，因為其中任何一件不成立都會讓設計垮掉。代理寫了一個用完即丟的 hook，指向一個記錄檔，然後確認實際上什麼東西會送達：

| 通道 | 會送到代理嗎 |
| --- | --- |
| exit 0，純標準輸出 | **不會。** 標記從來沒有送達 |
| exit 0，JSON 的 `hookSpecificOutput.additionalContext` | 會 |
| exit 2，標準錯誤輸出 | 會，以阻斷式錯誤的形式 |

**一個只是把結果 `echo` 出來的 hook，會執行、會正常結束，然後什麼都沒告訴任何人。** 當一個 hook 明明有觸發卻好像沒有作用時，這是第一個該檢查的地方。這道檢查本身刻意只做建議而不阻斷：在這個事件上以非零狀態結束會讓該輪無法結束，只要有一個修不掉的錯誤就會永遠繞下去。

檢查的後半段在找 Node 會拒絕的指定字串。它不是直接 grep `scripts/` 目錄，而是從那裡往外走過 import 圖，這個差別並不只是表面功夫。那些指令碼對 `src/` 有 17 條依賴，而 Node 會把它們拉進來的東西一併載入，所以同一條規則也適用於那些檔案。另一方面，`src/i18n/article-locale.ts` 用沒有副檔名的方式 import `"./config"`，卻完全沒有問題。`scripts/` 底下沒有任何東西走得到它，而走得到它的一切都由 Vite 負責解析。

*Figure — ImportGraphWalk: src/ 裡兩個沒有副檔名的 import，判定卻完全相反。分開它們的只有可達性，而那正是平坦的 grep 看不到的東西。*

用 grep 掃 `scripts/` 的做法，只能在兩種結果之間二選一：漏掉 `src/` 裡真正的損壞，或是把正確的檔案報成壞的。**決定判定的是可達性**，而它算起來很便宜。

## 驗證

這道關卡跑完只要 0.86 秒，相較之下單獨的 `tsc --noEmit` 是 1.3 秒，`astro check` 是 11.4 秒，後者因此沒有被放進來。**當工作目錄乾淨時，它什麼都不會輸出**，安靜的一輪就維持安靜。

把它指向最初那個 bug，它會說出編輯器從來沒說過的話：

```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`**。第二個正是以檔案為單位的編輯器診斷做不到的事。

合併前的程式碼審查找出三個缺陷，其中最嚴重的一個就在失敗路徑上。`existsSync` 對目錄會回傳 `true`，因此對目錄呼叫 `readFileSync` 會丟出 `EISDIR`。沒有任何東西接住它，於是整道檢查會什麼都不輸出就結束，`tsc` 連跑都沒跑到。**對一道以沉默作為成功訊號的關卡來說，這種失敗方式和成功根本分不出來。** 另外兩個是誤判，把註解掉的 import 或換行折起來的 `import type` 讀成致命問題。

> **挖哩咧**
>
> **第一次修正並不完整，是重新測試才抓到的。** 把註解變成空白之後，五種誤判裡有四種消失了，但一個裝著範例程式碼的模板字串仍然被報成壞掉的 import。現在反引號字串也會一併變成空白，這麼做是安全的，因為靜態 import 的指定字串不可能是模板字串。

## 總結

- **`extends` 解析失敗會丟掉所有繼承而來的編譯器選項。** 由此產生的錯誤指向的是 flag，而不是那條解析不到的路徑，所以請先診斷 `extends` 的失敗，其餘的都當成它的下游。
- **讓語言伺服器過期的是 `pnpm install`。** store 目錄名稱帶有每次 install 都會變的 peer hash，快取起來的解析路徑會因此不再存在。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)
