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

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

每一個請求大致長這樣︰
seq、固定 type: "request"、命令名稱 command,以及對應的 arguments。{
"seq": 1,
"type": "request",
"command": "open",
"arguments": { "file": "c:/path/to/file.ts" }
}Content-Length 這種 header,後面接一段 JSON,裡面包含對應結果,例如 completions、quickinfo 或 diagnostics。這整套協定的型別定義放在 protocol.d.ts,client(也就是編輯器或外掛)只要照這份規格對話,就能把任何 UI 操作(按快捷鍵、移動游標)轉成 request 給 tsserver。
實務上,你在編輯器裡做的事,會被翻成諸如:
open / close:通知哪個檔案正在被編輯。change:游標停在某檔案、某位置,文字變動了,請 tsserver 更新記憶體內容。completions、definition、rename、references:請它幫你算自動完成、跳轉定義、重構等等。這也是為什麼就算你檔案還沒存檔,TypeScript 錯誤照樣會即時跳出來 tsserver 看的是記憶體版本,而不是硬碟上的舊檔案。
tsserver 不會單看一個檔案,而是用「專案」(project)的概念在管理整個 codebase。
官方有提到三種專案型態 - Project System︰
tsconfig.json 的專案。tsconfig.json,tsserver 會幫它建立一個 推論專案。/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";這背後的專案系統,是你在大型專案裡感受到 tsserver 開始變慢、記憶體開始變肥 的關鍵。其中適當分專案、調整 tsconfig 範圍,常常比單純抱怨「TypeScript 好重」更有用。
通常有兩種整合方式︰
typescript-language-server,在外面包一層,對外講 Language Server Protocol(LSP),對內轉成 tsserver 的協定與命令。如果說 tsserver 是 TypeScript 的核心,那 Language Server Protocol(LSP)就是一套讓 tsserver 可以被很多不同編輯器、IDE 共用的通用語言。
它用 JSON-RPC 定義了一組標準的請求與回應格式,讓任何編輯器只要會講這套協定,就能跟任何支援 LSP 的語言伺服器合作。
關鍵目標只有一個︰把編輯器和語言工具拆開。
這樣一來,你就可以有:
LSP 的消息格式建立在 JSON‑RPC 2.0 之上,每一個操作都是一個 request、response 或 notification。
大致流程可以分成這樣︰
initialize 請求,帶上自己支援的能力(capabilities),例如會不會處理 codeAction、rename 等等。textDocument/didOpen 給 server,附上目前整個檔案內容。textDocument/didChange,用增量方式描述「第幾行第幾列刪掉、插入了什麼」。textDocument/didClose,讓 server 可以釋放一些記憶體。textDocument/completion:請求自動完成項目。textDocument/definition:請求跳轉定義。textDocument/hover:請求 hover 資訊。textDocument/rename:請求重命名 refactor。textDocument/publishDiagnostics 通知,告訴編輯器「這段程式有錯」或「這裡有警告」。這整套流程的好處是︰
typescript-language-server 幫你橋起來前面提到,原生的 tsserver 有自己的一套 JSON 協定,而不是 LSP。
為了讓 TypeScript 可以在各種 LSP 編輯器裡重用,社群做了一個 typescript-language-server,專門負責 把 LSP 譯成 tsserver 語言。
它的角色可以這樣理解︰
initialize、textDocument/* 等方法。completions、definition、rename 等。因此,在 Neovim、Zed、其他 LSP 支援編輯器裡,畫面上看到的 TypeScript 經驗,本質上還是那顆熟悉的 tsserver,只是多了一層 LSP adapter。 - Zed TypeScript
我用的編輯器是 client,TypeScript 的語言能力集中在 tsserver,LSP 是一套標準化的溝通協定,
typescript-language-server 則是把 tsserver 包成 LSP server,讓更多工具能用。

有了這層理解,你在調整工具鏈、查看語言服務 log、甚至 debug weird 行為 時,就不再只是「盲按重啟」,而是可以有意識地判斷「現在是 LSP 層出問題,還是 tsserver 層出問題」。
理解 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 都很常犯類似的錯誤,就是 不要在既有的原始型別之上再自己去覆蓋另一個型別