寫 TypeScript 不只靠型別︰tsserver、LSP 與開發思維一次搞懂

這篇文章從實務角度深入介紹 TypeScript 開發體驗背後的關鍵元件,包括 tsserver 與 Language Server Protocol(LSP),說明編輯器如何取得自動完成、跳轉定義與錯誤診斷等智慧提示。內容由淺入深,帶你理解專案類型(Configured / Inferred Project)、typescript-language-server 的角色,以及為什麼「穩定的邊界型別」與型別推論(infer)對大型專案維護與提升開發效率至關重要,並提供實用範例與最佳實務建議。

CChia1104
文章13 分鐘閱讀

一開始,我們其實都只在跟編輯器互動

多數人在寫 TypeScript 時,可能裝好 VS Code 跟 TypeScript 擴充功能,而後開始寫程式然後看到 autocomplete、錯誤提示、跳轉定義就覺得「TypeScript 好強」。

TyoeScript autocomplete

但對大部分開發者來說,這一切就像黑盒子:編輯器到底怎麼知道型別?那些提示是誰算出來的?

如果你只把 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 訊息。

init tsserver

每一個請求大致長這樣︰

  • 請求(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 更新記憶體內容。
  • completionsdefinitionrenamereferences:請它幫你算自動完成、跳轉定義、重構等等。

這也是為什麼就算你檔案還沒存檔,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。

大致流程可以分成這樣︰

  1. 啟動階段
  • 編輯器先啟動 language server,送出 initialize 請求,帶上自己支援的能力(capabilities),例如會不會處理 codeAction、rename 等等。
  • Server 回傳它支援的功能列表,像是 completion、hover、definition、diagnostic 等。
  1. 同步檔案內容
  • 當你開啟一個檔案,編輯器會送 textDocument/didOpen 給 server,附上目前整個檔案內容。
  • 每次你打字、刪字,編輯器都會送 textDocument/didChange,用增量方式描述「第幾行第幾列刪掉、插入了什麼」。
  • 當你關掉檔案,會送 textDocument/didClose,讓 server 可以釋放一些記憶體。
  1. 請求智慧功能
  • 你移動游標、按下快捷鍵時,編輯器會發送各種 request,例如:
    • textDocument/completion:請求自動完成項目。
    • textDocument/definition:請求跳轉定義。
    • textDocument/hover:請求 hover 資訊。
    • textDocument/rename:請求重命名 refactor。
  • Server 收到請求後,用自己對語言的理解(AST、型別系統、專案圖等)算出結果,再以 JSON 回傳給編輯器讓它渲染。
  1. 背景診斷與提示
  • 就算你沒有按任何快捷鍵,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,支援標準的 initializetextDocument/* 等方法。
  • 對內:啟動一個 tsserver 進程,使用 tsserver 自己的協定發送 command,例如 completionsdefinitionrename 等。
  • 中間:把 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,讓更多工具能用。

restart tsserver

有了這層理解,你在調整工具鏈、查看語言服務 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>
  );
};

TS inferred type

我們這裡在做 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>
  );
};

TS fake type error

這裡就會看到,我們 FakeProduct 少定義了 name,這時 Array.mapcallbackfn 會優先吃我們給的型別 FakeProduct,進而無法在 item 中呼叫 name 這個屬性。

這狀況再自己過去參與過的專案或是最近看 AI 生成的 code 都很常犯類似的錯誤,就是 不要在既有的原始型別之上再自己去覆蓋另一個型別

幾個具體建議

  • 優先寫出 穩定的邊界型別:例如 API response、Domain Model 等,這是整個專案推論的基礎。
  • 避免動不動就 any 或大量 as:這些都會讓 tsserver 的推論斷掉,後面所有提示都變得不可信。
  • 善用 satisfiesas const 等語法,讓推論更精確,減少「字串打錯卻沒被抓到」的情況。

Written by: Chia1104 CC BY-NC-SA 4.0

Chia1104
©
Chia1104