寫 TypeScript 不只靠型別︰tsserver、LSP 與開發思維一次搞懂
這篇文章從實務角度深入介紹 TypeScript 開發體驗背後的關鍵元件,包括 tsserver 與 Language Server Protocol(LSP),說明編輯器如何取得自動完成、跳轉定義與錯誤診斷等智慧提示。內容由淺入深,帶你理解專案類型(Configured / Inferred Project)、typescript-language-server 的角色,以及為什麼「穩定的邊界型別」與型別推論(infer)對大型專案維護與提升開發效率至關重要,並提供實用範例與最佳實務建議。
一開始,我們其實都只在跟編輯器互動
多數人在寫 TypeScript 時,可能裝好 VS Code 跟 TypeScript 擴充功能,而後開始寫程式然後看到 autocomplete、錯誤提示、跳轉定義就覺得「TypeScript 好強」。

但對大部分開發者來說,這一切就像黑盒子:編輯器到底怎麼知道型別?那些提示是誰算出來的?
如果你只把 TypeScript 當成「會報錯的 JavaScript」,其實就錯過幾個重要概念:tsserver 是什麼,LSP 又在做什麼。
什麼是 tsserver
多數人只知道 tsc 是 TypeScript 的編譯器,然而現在各種 IDE / 編輯器能夠做到 autocomplete、跳轉定義、重構建議等,都要歸功於 tsserver。
它是一個獨立執行的 Node 程式,內建 TypeScript 編譯器與語言服務,並透過一套 JSON 通訊協定跟編輯器對話。
簡單說,tsserver 做的事情是︰
- 一直待在背景,維護整個專案的結構與型別資訊。
- 接收編輯器送來的請求(例如「這個位置幫我算自動完成」)。
- 回傳對應的結果(例如 completion 列表、quick info、diagnostics 等)。
tsserver 如何跟編輯器溝通
我們裝好 typescript 套件後,會在 node_modules/typescript/lib/tsserver.js 找到這個執行檔,編輯器通常會啟動它當成 child process,透過 stdin / stdout 傳 JSON 訊息。

每一個請求大致長這樣︰
- 請求(request)
- 有序號
seq、固定type: "request"、命令名稱command,以及對應的arguments。 - 例如「開啟檔案」會是:
{ "seq": 1, "type": "request", "command": "open", "arguments": { "file": "c:/path/to/file.ts" } } - 有序號
- 回應與事件(response / event)
- 都會加上像
Content-Length這種 header,後面接一段 JSON,裡面包含對應結果,例如 completions、quickinfo 或 diagnostics。
- 都會加上像
這整套協定的型別定義放在 protocol.d.ts,client(也就是編輯器或外掛)只要照這份規格對話,就能把任何 UI 操作(按快捷鍵、移動游標)轉成 request 給 tsserver。
實務上,你在編輯器裡做的事,會被翻成諸如:
open/close:通知哪個檔案正在被編輯。change:游標停在某檔案、某位置,文字變動了,請 tsserver 更新記憶體內容。completions、definition、rename、references:請它幫你算自動完成、跳轉定義、重構等等。
這也是為什麼就算你檔案還沒存檔,TypeScript 錯誤照樣會即時跳出來 tsserver 看的是記憶體版本,而不是硬碟上的舊檔案。
它怎麼看你的專案:Configured / External / Inferred 專案
tsserver 不會單看一個檔案,而是用「專案」(project)的概念在管理整個 codebase。
官方有提到三種專案型態 - Project System︰
- Configured Project
- 有
tsconfig.json的專案。 - 設定檔同時定義:專案根目錄、要包含哪些檔案、要用什麼 compiler options。
- 這是一般 TypeScript 專案最常見也最推薦的用法。
- 有
- External Project
- 給有自己專案檔格式的 host 用,例如 Visual Studio 的 .csproj。
- Inferred Project
- 當某個檔案往上找不到任何
tsconfig.json,tsserver 會幫它建立一個 推論專案。 - 這個 inferred project 會包含這個檔案本身,以及透過三斜線 reference 或 module import 連出去的檔案;compiler options 則用預設值,host 還可以自訂這些預設設定。
next-env.d.ts /// <reference types="next" /> /// <reference types="next/image-types/global" /> import "./.next/dev/types/routes.d.ts"; - 當某個檔案往上找不到任何
這背後的專案系統,是你在大型專案裡感受到 tsserver 開始變慢、記憶體開始變肥 的關鍵。其中適當分專案、調整 tsconfig 範圍,常常比單純抱怨「TypeScript 好重」更有用。
誰真的在直接跟 tsserver 溝通
通常有兩種整合方式︰
- 直接用 tsserver 協定
- VS Code 官方 TypeScript extension 就是這樣做:用 TypeScript 寫一個 client,直接用這份 JSON 協定跟 tsserver 溝通。
- 其他一些編輯器外掛(例如早期的 Sublime TypeScript 插件、Emacs Tide 等)也會自己實作 client。
- 包成 LSP server 間接使用
- 社群做了
typescript-language-server,在外面包一層,對外講 Language Server Protocol(LSP),對內轉成 tsserver 的協定與命令。 - Neovim、一些通用 LSP client,不需要理解 tsserver 的細節,只要會講 LSP,就可以間接使用 TypeScript 語言服務。
- 社群做了
LSP 是什麼?讓語言服務可以被「多個編輯器共享」的協定
如果說 tsserver 是 TypeScript 的核心,那 Language Server Protocol(LSP)就是一套讓 tsserver 可以被很多不同編輯器、IDE 共用的通用語言。
它用 JSON-RPC 定義了一組標準的請求與回應格式,讓任何編輯器只要會講這套協定,就能跟任何支援 LSP 的語言伺服器合作。
關鍵目標只有一個︰把編輯器和語言工具拆開。
這樣一來,你就可以有:
- 一個 TypeScript 語言伺服器,同時給 VS Code、Neovim、Zed 等多個編輯器用。
- 一個編輯器,只要支援 LSP,就能接上 TypeScript、Rust、Go、Python 等各種 language server。
LSP 在傳什麼?從「打字」到「得到提示」之間發生的事
LSP 的消息格式建立在 JSON‑RPC 2.0 之上,每一個操作都是一個 request、response 或 notification。
大致流程可以分成這樣︰
- 啟動階段
- 編輯器先啟動 language server,送出
initialize請求,帶上自己支援的能力(capabilities),例如會不會處理 codeAction、rename 等等。 - Server 回傳它支援的功能列表,像是 completion、hover、definition、diagnostic 等。
- 同步檔案內容
- 當你開啟一個檔案,編輯器會送
textDocument/didOpen給 server,附上目前整個檔案內容。 - 每次你打字、刪字,編輯器都會送
textDocument/didChange,用增量方式描述「第幾行第幾列刪掉、插入了什麼」。 - 當你關掉檔案,會送
textDocument/didClose,讓 server 可以釋放一些記憶體。
- 請求智慧功能
- 你移動游標、按下快捷鍵時,編輯器會發送各種 request,例如:
textDocument/completion:請求自動完成項目。textDocument/definition:請求跳轉定義。textDocument/hover:請求 hover 資訊。textDocument/rename:請求重命名 refactor。
- Server 收到請求後,用自己對語言的理解(AST、型別系統、專案圖等)算出結果,再以 JSON 回傳給編輯器讓它渲染。
- 背景診斷與提示
- 就算你沒有按任何快捷鍵,server 也可以主動送
textDocument/publishDiagnostics通知,告訴編輯器「這段程式有錯」或「這裡有警告」。 - 編輯器拿到這些診斷,再決定要怎麼顯示紅線、警告圖示、Problems 面板等。
這整套流程的好處是︰
- 編輯器不需要懂 TypeScript、Rust、Go 的細節,只要會發 LSP 訊息。
- LSP 也不在乎前端 UI 長怎樣,只要有 LSP client,它就能服務新的工具。
TypeScript 怎麼接上 LSP:typescript-language-server 幫你橋起來
前面提到,原生的 tsserver 有自己的一套 JSON 協定,而不是 LSP。
為了讓 TypeScript 可以在各種 LSP 編輯器裡重用,社群做了一個 typescript-language-server,專門負責 把 LSP 譯成 tsserver 語言。
它的角色可以這樣理解︰
- 對外:扮演 Language Server,完全講 LSP,支援標準的
initialize、textDocument/*等方法。 - 對內:啟動一個 tsserver 進程,使用 tsserver 自己的協定發送 command,例如
completions、definition、rename等。 - 中間:把 LSP 的 request 轉換成對應的 tsserver command,把 tsserver 的 response 再轉回 LSP 格式送給編輯器。
因此,在 Neovim、Zed、其他 LSP 支援編輯器裡,畫面上看到的 TypeScript 經驗,本質上還是那顆熟悉的 tsserver,只是多了一層 LSP adapter。 - Zed TypeScript
我用的編輯器是 client,TypeScript 的語言能力集中在 tsserver,LSP 是一套標準化的溝通協定,
typescript-language-server 則是把 tsserver 包成 LSP server,讓更多工具能用。

有了這層理解,你在調整工具鏈、查看語言服務 log、甚至 debug weird 行為 時,就不再只是「盲按重啟」,而是可以有意識地判斷「現在是 LSP 層出問題,還是 tsserver 層出問題」。
開發思維:寫 TypeScript 不只是在「讓型別過關」
理解 tsserver 跟 LSP 之後,我們可以把焦點放回到「寫 TypeScript 的思維」。
剛剛前面提 Project 的概念時有講到一個 TypeScript 很重要的概念 Infer,tsserver 會根據你的程式碼與設定,推導出整個專案的型別關係圖,再提供 refactor、跳轉與錯誤資訊。
這意味著千萬 不要再重複定義型別,我認為這觀念非常重要,甚至可以寫給 AI 做為 Top Rule,這是什麼意思
我們這裡拿 API response 做舉例,假設我們要跟 API 獲得一個列表資訊:
interface Product {
id: string;
name: string;
items: Product[];
}
const getProducts = async () => {
const products = await fetch("https://api.example.com/products").then(
(res) => res.json() as Promise<Product[]>
);
return products;
};這裡我們還是要先定義好 穩定的邊界型別
const Products = () => {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
getProducts().then(setProducts);
}, []);
return (
<div>
{products.map((product, index, products) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
};
我們這裡在做 API 呼叫的方法跟 setState 的方法時還是要定義一個 穩定的邊界型別 給 tsserver,但當我們在做 Array.map 呼叫時當中的 callbackfn 自動會去 infer 這 products 的原始型別
假設我們這裡在自己定義另一個型別給他,會發生什麼是:
interface FakeProduct {
id: string;
items: FakeProduct[];
}
const Products = () => {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
getProducts().then(setProducts);
}, []);
return (
<div>
{products.map((product: FakeProduct) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
};
這裡就會看到,我們 FakeProduct 少定義了 name,這時 Array.map 的 callbackfn 會優先吃我們給的型別 FakeProduct,進而無法在 item 中呼叫 name 這個屬性。
這狀況再自己過去參與過的專案或是最近看 AI 生成的 code 都很常犯類似的錯誤,就是 不要在既有的原始型別之上再自己去覆蓋另一個型別
幾個具體建議
- 優先寫出 穩定的邊界型別:例如 API response、Domain Model 等,這是整個專案推論的基礎。
- 避免動不動就
any或大量as:這些都會讓 tsserver 的推論斷掉,後面所有提示都變得不可信。 - 善用 satisfies、as const 等語法,讓推論更精確,減少「字串打錯卻沒被抓到」的情況。
Written by: Chia1104 CC BY-NC-SA 4.0