本文 AI 產出,尚未審核

TypeScript 進階型別操作 – Template Literal Types


簡介

在 TypeScript 中,型別系統不再只是靜態的「檢查」工具,它同時具備了 產生 型別的能力。自 4.1 版起引入的 template literal types(模板字面量型別)讓我們可以像在 JavaScript 中使用字串模板一樣,直接在型別層面組合、拆解與轉換字串。

這項功能對於 API 路由、CSS‑in‑JS、國際化字串、以及任何需要以字串規則來描述型別的情境,都能大幅提升開發體驗與程式碼安全性。本文將一步步帶你了解其核心概念、實作方式,以及在真實專案中如何善加利用。


核心概念

1. 基本語法

Template literal types 的語法與 JavaScript 的字串模板相似:使用反引號 `...`,在型別位置插入 型別變數(或其他型別)並以 ${} 包起來。

type Greeting = `Hello, ${string}!`;

Greeting 會被推斷成所有符合「Hello, 任意字串!」的字串型別,例如 "Hello, Alice!""Hello, 123!"

重點:Template literal types 只在 型別層面 運作,編譯後仍會產生普通的 string

2. 結合聯合型別(Union)

將聯合型別放入 ${} 內,會產生笛卡兒積的結果。

type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";

type Palette = `${Color}-${Shade}`;
// -> "red-light" | "red-dark" | "green-light" | ...

Palette 為 3 × 2 = 6 種可能的字串組合,讓我們在編譯期就能捕捉錯誤。

3. 以型別推斷 (infer) 取得子字串

透過條件型別與 infer,可以從模板字面量型別中抽取特定子字串。

type ExtractDomain<T extends string> =
  T extends `https://${infer Domain}.com` ? Domain : never;

type D1 = ExtractDomain<"https://google.com">;   // "google"
type D2 = ExtractDomain<"https://example.org">; // never

這裡的 infer Domain 會把符合模式的部分抓出來,供後續型別使用。

4. 與映射型別 (Mapped Types) 結合

利用 keyof 與模板字面量,我們可以自動產生一組相關的型別鍵值對。

type EventMap = {
  click: MouseEvent;
  keyup: KeyboardEvent;
};

type ListenerName = keyof EventMap;               // "click" | "keyup"
type ListenerFn = `on${Capitalize<ListenerName>}`; // "onClick" | "onKeyup"

type Listeners = {
  [K in ListenerFn]?: (e: EventMap[Uncapitalize<Extract<K, `on${infer N}`>>]) => void;
};

/*
等同於:
type Listeners = {
  onClick?: (e: MouseEvent) => void;
  onKeyup?: (e: KeyboardEvent) => void;
}
*/

透過 CapitalizeUncapitalize、以及 Extract,我們把事件名稱自動映射成對應的監聽函式型別。

5. 產生「字串常數」型別的工具型別

有時候想把一個字串陣列轉成聯合型別,可搭配 as const 與模板字面量達成。

const routes = ["home", "about", "contact"] as const;
type Route = typeof routes[number];               // "home" | "about" | "contact"
type RoutePath = `/${Route}`;                      // "/home" | "/about" | "/contact"

這樣的型別可直接用於路由函式的參數,保證不會傳入不存在的路徑。


程式碼範例

以下提供 5 個實務導向的範例,每個範例皆附上說明與可能的使用情境。

範例 1 – API 路徑型別安全

type Resource = "users" | "posts" | "comments";
type Method = "GET" | "POST" | "PUT" | "DELETE";

type ApiEndpoint = `${Method} /api/${Resource}/${string}`;

function callApi(endpoint: ApiEndpoint, payload?: unknown) {
  // 只接受符合模板的字串
  console.log(`Calling ${endpoint}`);
}

/* 正確使用 */
callApi("GET /api/users/123");          // ✅
callApi("POST /api/posts/create");      // ✅

/* 錯誤使用,編譯時會報錯 */
callApi("FETCH /api/users/123");        // ❌ Type '"FETCH /api/users/123"' is not assignable

實務意義:即使後端路徑規則改變,只要更新 ResourceMethod 聯合型別,即可自動同步所有呼叫點的型別檢查。

範例 2 – CSS‑in‑JS 風格名稱

type Color = "primary" | "secondary" | "danger";
type Size = "sm" | "md" | "lg";

type ClassName = `btn-${Color}-${Size}`;

const btnClass: ClassName = "btn-primary-md"; // ✅
const wrongClass: ClassName = "btn-primary-xl"; // ❌ 編譯錯誤

實務意義:在使用 styled‑components 或 emotion 時,直接以型別限制 className,避免拼寫錯誤或不支援的樣式組合。

範例 3 – 國際化鍵值的型別抽取

type I18nKey = `msg_${"welcome" | "goodbye"}_${"en" | "zh"}`;

type ExtractLocale<T extends I18nKey> =
  T extends `msg_${infer _}_${infer L}` ? L : never;

type Locale = ExtractLocale<"msg_welcome_zh">; // "zh"
type Invalid = ExtractLocale<"msg_hello_en">; // never

實務意義:在多語系專案中,能夠從字串鍵自動取得語言代碼,搭配 Record<Locale, string> 建立對應的翻譯表。

範例 4 – 動態生成 Redux Action 型別

type Entity = "user" | "post";
type Action = "add" | "remove" | "update";

type ActionType = `${Uppercase<Action>}_${Uppercase<Entity>}`;

interface ActionPayload<T extends ActionType> {
  type: T;
  payload: any;
}

type AddUser = ActionPayload<"ADD_USER">;   // { type: "ADD_USER"; payload: any }
type RemovePost = ActionPayload<"REMOVE_POST">;

實務意義:在大型 Redux 或 Zustand 狀態管理中,透過模板字面量自動產生所有合法的 action type,減少手寫錯誤。

範例 5 – 以模板型別建構檔案路徑工具

type Dir = "src" | "dist" | "tests";
type Ext = "ts" | "js" | "json";

type FilePath = `${Dir}/${string}.${Ext}`;

function readFile(path: FilePath) {
  // 只允許符合規則的路徑
  console.log(`Reading ${path}`);
}

readFile("src/app/main.ts");   // ✅
readFile("public/index.html"); // ❌ 編譯錯誤

實務意義:在 Node.js 工具或 CLI 中,保證只處理特定目錄與副檔名的檔案,降低意外讀取錯誤檔案的風險。


常見陷阱與最佳實踐

陷阱 說明 解決方案
過度使用 string 若模板內部仍使用寬鬆的 string,會失去型別保護,例如 `${string}-${string}` 會退化成 string 盡量使用 具體的聯合型別字面量型別,如 `` `${"a"
遞迴模板導致編譯緩慢 大量遞迴或深層條件型別會讓 TypeScript 編譯器卡頓。 保持模板深度在 5 層以內,或拆分成多個小型工具型別。
infer 無法推斷空字串 當模板中可能出現空字串時,infer 會回傳 never,導致意外錯誤。 為可能的空值提供備援型別,例如 T extends \prefix${infer X}suffix` ? X : ""`。
字面量型別與字串聯合的衝突 string 放入聯合型別會把整個型別提升為 string 使用 Exclude<string, ...>Extract<...> 先移除寬鬆的 string
模板字面量無法直接處理數字 雖然 ${number} 合法,但會把結果視為 string,無法保留數字資訊。 若需要保留數字型別,可配合 映射型別自訂型別守衛 進行二次驗證。

最佳實踐

  1. 先定義基礎聯合型別:將所有可能的字串片段先以 type X = "a" | "b" 的形式寫好,再組合成模板。
  2. 利用 as const 鎖定陣列:從常數陣列產生聯合型別,可避免手寫錯字。
  3. 把模板型別封裝成工具型別:如 type Path<T extends string> = \/${T}``,提升可讀性與重用性。
  4. 在 IDE 中啟用 noImplicitAnystrict:確保模板型別的推斷不會意外變成 any
  5. 單元測試結合 tsd:使用 tsdtype-tests 來驗證型別的預期結果,防止未來改動破壞合成邏輯。

實際應用場景

  1. 前端路由系統:使用模板字面量生成所有合法的路由字串,讓 navigate("/user/123") 在編譯期即能檢查路徑格式。
  2. CSS‑in‑JS 框架:自動產生 className 的型別,避免因手寫錯誤導致樣式失效。
  3. 多語系 i18n:從翻譯鍵抽取語言代碼與命名空間,配合 Record<Locale, string> 建立安全的翻譯物件。
  4. API 客戶端 SDK:把 HTTP 方法、資源名稱、參數位置全部寫成模板型別,生成完整且受型別保護的請求函式。
  5. CI/CD 檔案檢查工具:限定只能讀取或寫入特定目錄與副檔名的檔案路徑,減少部署時的意外。

總結

Template literal types 為 TypeScript 的型別系統注入了 「字串層級的程式化」 能力。透過簡潔的語法,我們可以:

  • 組合 多個字串聯合型別產生所有合法組合。
  • 抽取 字串中的子片段,讓型別資訊回流到程式碼。
  • 配合 映射型別、條件型別與 infer,建立高度自動化且安全的型別工具。

在實務開發中,善用此功能能夠 提前捕捉錯誤、提升可讀性、減少重複程式碼,特別適合大型前端框架、API SDK、以及任何需要嚴格字串規範的情境。只要遵循上述的最佳實踐,避免常見陷阱,就能在專案中穩健地運用 template literal types,讓 TypeScript 的型別檢查更貼近真實的業務邏輯。

祝你在 TypeScript 的進階型別世界玩得開心,寫出更安全、更易維護的程式碼!