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;
}
*/
透過 Capitalize、Uncapitalize、以及 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
實務意義:即使後端路徑規則改變,只要更新
Resource或Method聯合型別,即可自動同步所有呼叫點的型別檢查。
範例 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,無法保留數字資訊。 |
若需要保留數字型別,可配合 映射型別 或 自訂型別守衛 進行二次驗證。 |
最佳實踐
- 先定義基礎聯合型別:將所有可能的字串片段先以
type X = "a" | "b"的形式寫好,再組合成模板。 - 利用
as const鎖定陣列:從常數陣列產生聯合型別,可避免手寫錯字。 - 把模板型別封裝成工具型別:如
type Path<T extends string> = \/${T}``,提升可讀性與重用性。 - 在 IDE 中啟用
noImplicitAny與strict:確保模板型別的推斷不會意外變成any。 - 單元測試結合
tsd:使用tsd或type-tests來驗證型別的預期結果,防止未來改動破壞合成邏輯。
實際應用場景
- 前端路由系統:使用模板字面量生成所有合法的路由字串,讓
navigate("/user/123")在編譯期即能檢查路徑格式。 - CSS‑in‑JS 框架:自動產生
className的型別,避免因手寫錯誤導致樣式失效。 - 多語系 i18n:從翻譯鍵抽取語言代碼與命名空間,配合
Record<Locale, string>建立安全的翻譯物件。 - API 客戶端 SDK:把 HTTP 方法、資源名稱、參數位置全部寫成模板型別,生成完整且受型別保護的請求函式。
- CI/CD 檔案檢查工具:限定只能讀取或寫入特定目錄與副檔名的檔案路徑,減少部署時的意外。
總結
Template literal types 為 TypeScript 的型別系統注入了 「字串層級的程式化」 能力。透過簡潔的語法,我們可以:
- 組合 多個字串聯合型別產生所有合法組合。
- 抽取 字串中的子片段,讓型別資訊回流到程式碼。
- 配合 映射型別、條件型別與
infer,建立高度自動化且安全的型別工具。
在實務開發中,善用此功能能夠 提前捕捉錯誤、提升可讀性、減少重複程式碼,特別適合大型前端框架、API SDK、以及任何需要嚴格字串規範的情境。只要遵循上述的最佳實踐,避免常見陷阱,就能在專案中穩健地運用 template literal types,讓 TypeScript 的型別檢查更貼近真實的業務邏輯。
祝你在 TypeScript 的進階型別世界玩得開心,寫出更安全、更易維護的程式碼!