TypeScript 高級類型實戰:從條件類型到類型體操
一次讓我重新認識類型的重構
去年我們對一個中後台專案做欄位改名:把 userProfile 上的 avatarUrl 改名為 avatar。這是個看似安全的改動——直到 TypeScript 在 31 個檔案裡同時報紅。我們本以為只是改個名字,結果編譯器把每一處拿 avatarUrl 拼接 CDN 位址、做空值兜底、傳給 <img src> 的地方都標了出來。
如果當時用的是 JavaScript,這些報錯會延遲到運行時、甚至到用戶瀏覽器裡才暴露。那一晚讓我意識到:類型不是負擔,是編譯器在替你做免費的程式碼評審。
這篇文章不講基礎語法,假設你已經會用 interface、泛型。我們直接鑽進那些「看起來很嚇人、用熟了捨不得丟」的高級類型。
一、先別急著「體操」,把地基打牢:keyof 與索引訪問
很多高級寫法都建立在兩個原語上。
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
type IdType = User["id"]; // number
type NameOrEmail = User["name" | "email"]; // string
keyof T 拿到所有鍵的聯合,T[K] 做索引訪問。把它們組合,就能寫出「根據鍵取對應值類型」的工具:
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: User = { id: 1, name: "Ada", email: "a@x.com" };
const name = pick(user, "name"); // 類型推斷為 string
注意第二個泛型參數 K extends keyof T——這是約束,保證呼叫方傳的 key 一定存在於物件上,傳錯直接編譯報錯。
typeof 在「值」上的妙用
typeof 在類型位置使用時,是把一個值反向推導成類型,常用於從運行時代碼裡提取類型,避免重複宣告:
const config = {
apiBase: "https://api.example.com",
timeout: 8000,
retries: 3,
};
type Config = typeof config;
// { apiBase: string; timeout: number; retries: number }
二、條件類型:類型的三元表達式
條件類型的形態是 T extends U ? X : Y,讀起來就是「如果 T 能賦值給 U,用 X,否則用 Y」。
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
標準庫裡的 Extract 和 Exclude 就是條件類型寫的。試著自己實作一遍,比看十遍文件都管用:
type MyExtract<T, U> = T extends U ? T : never;
type MyExclude<T, U> = T extends U ? never : T;
type T0 = MyExtract<"a" | "b" | "c", "a" | "c">; // "a" | "c"
type T1 = MyExclude<"a" | "b" | "c", "a" | "c">; // "b"
理解關鍵點:當 T 是聯合類型時,條件類型會對每個成員分別求值,再把結果聯合起來。這正是 MyExclude 能逐個剔除成員的原因。
infer:從類型裡「掏」出一部分
infer 只能在 extends 條件分支裡用,意思是「這裡先別寫死,讓編譯器幫我推斷」。它讓類型具備了「解構」能力。
type First<T extends unknown[]> = T extends [infer Head, ...unknown[]]
? Head
: never;
type F = First<[string, number, boolean]>; // string
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function loadUser() {
return { id: 1, name: "Ada" };
}
type Loaded = MyReturnType<typeof loadUser>; // { id: number; name: string }
infer 可以出現在回傳值、參數、Promise 內部、陣列元素等任意位置。Awaited<T> 就是靠它逐層剝開 Promise 的:
type MyAwaited<T> = T extends Promise<infer U>
? U extends Promise<unknown>
? MyAwaited<U>
: U
: T;
type R = MyAwaited<Promise<Promise<number>>>; // number
三、映射類型與 key 重映射
映射類型讓你「遍歷一個類型的所有鍵」,像對物件做 map。
type PartialUser = {
[K in keyof User]?: User[K];
};
// { id?: number; name?: string; email?: string }
+/- 修飾符能增刪 readonly 和 ?:
type Writable<T> = {
-readonly [K in keyof T]: T[K];
};
key 重映射(TypeScript 4.1+)
as 子句可以在映射時重寫鍵名,這是實作各種 getter/事件映射的利器:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// {
// getId: () => number;
// getName: () => string;
// getEmail: () => string;
// }
結合 Exclude 還能「過濾」掉某些鍵:
type MethodsOnly<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};
當 as 的結果是 never,該鍵就被直接剔除。這個技巧在裁剪類型時極好用。
四、模板字面量類型
模板字面量類型可以拼接字串字面量,常用於約束事件名、路由、CSS 屬性等:
type Lang = "zh" | "en" | "ja";
type Path = `/i18n/${Lang}`; // "/i18n/zh" | "/i18n/en" | "/i18n/ja"
type EventName = `on${Capitalize<"click" | "change" | "submit">}`;
// "onClick" | "onChange" | "onSubmit"
實戰中用它給一個 CSS 屬性生成類型安全的 setter:
type Spacing = "sm" | "md" | "lg";
type MarginClass = `m-${Spacing}` | `mt-${Spacing}` | `mb-${Spacing`;
function cx(...classes: MarginClass[]) {
return classes.join(" ");
}
cx("mt-md", "mb-lg"); // OK
// cx("padding-sm"); // 報錯:不在允許集合內
這類約束在寫元件庫、DSL、配置校驗時特別香——把「拼字錯誤」擋在編譯期。
五、遞歸類型
類型可以是遞歸的。配合 readonly 和 object 判斷,能表達深層不可變結構:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
const cfg: DeepReadonly<User> = { id: 1, name: "Ada", email: "a@x.com" };
// cfg.name = "Bob"; // 報錯:無法分配到唯讀屬性
再寫一個 DeepPartial,常用於「表單草稿」——用戶只填了一部分欄位,其餘可空:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
JSON 序列化類型也是遞歸題的經典:
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
六、實戰:解析查詢字串類型
類型體操圈有一道名題:把 "a=1&b=2&c=3" 這樣的字串,解析成 { a: string; b: string; c: string } 類型。完整實作挺長,這裡給一個能跑、易讀的版本,思路是「按 & 拆、按 = 拆、逐個累加」:
type Split<S extends string, D extends string> =
string extends S ? string[]
: S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
type ParsePair<P extends string> =
P extends `${infer K}=${infer V}` ? { [k in K]: V } : {};
type Merge<T> =
(T extends any ? (k: T) => void : never) extends (k: infer U) => void
? U
: never;
type ParseQueryString<S extends string> =
Merge<ParsePair<Split<S, "&">[number]>>;
type Q = ParseQueryString<"a=1&b=2&c=3">;
// { a: "1" } & { b: "2" } & { c: "3" } 的交叉,即三者合併
這道題的價值不在「記住答案」,而在於它逼你把 infer、遞歸、as 重映射、[number] 取聯合成員全部串起來。建議自己寫一遍再對照。
七、類型與運行時的邊界
類型在編譯後會被抹除,所以「類型裡做的判斷」運行時不存在。需要運行時校驗時,有兩個常見夥伴:
類型守衛
function isUser(v: unknown): v is User {
return (
typeof v === "object" &&
v !== null &&
"id" in v &&
"name" in v
);
}
satisfies:既要值正確,又要保留字面量類型
satisfies 是 4.9 引入的寶藏。它不拓寬類型,卻能檢查值是否符合某類型:
const routes = {
home: "/",
user: "/users/:id",
} satisfies Record<string, string>;
type RouteName = keyof typeof routes; // "home" | "user"(而非 string)
相比 as,satisfies 是「校驗但不改寫」,安全得多。
與 zod 配合
當你需要「運行時也驗證」,可以用 zod 這類庫,再用 z.infer 把 schema 推導回類型,做到一份定義兩端共用:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer<typeof UserSchema>; // 與 schema 永遠同步
八、性能坑:實例化深度超限
類型越複雜,編譯器要做的工作越多。條件類型裡寫 T extends ... ? ... : ... 時,如果遞歸層級太深,會撞上 error TS2589: Type instantiation is excessively deep and possibly infinite。
幾個緩解辦法:
- 用陣列索引代替超長元組鏈:
T extends [infer H, ...infer R]通常比層層巢狀的三元更穩。 - 給遞歸加終止條件,並確保每次遞歸都在「縮小」類型。
- 避免無意義的深層映射,必要時拆成多個中間類型,讓編譯器分步求解。
- 真遇到 TS2589,先想「我是不是在類型裡重寫了一個本該運行時的邏輯」。類型系統不是圖靈機,強行模擬迴圈往往得不償失。
九、為 API 回應建模
把上面這些用到真實場景:給 fetch 包一層類型安全的客戶端,讓「成功/失敗」在類型上可區分。
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
async function getJson<T>(url: string): Promise<ApiResult<T>> {
try {
const res = await fetch(url);
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
const data = (await res.json()) as T;
return { ok: true, data };
} catch (e) {
return { ok: false, error: String(e) };
}
}
interface Order { id: number; total: number }
const r = await getJson<Order>("/api/orders/1");
if (r.ok) {
console.log(r.data.total); // 類型收窄後安全訪問
} else {
console.error(r.error);
}
把「失敗」做成顯式分支而不是拋異常,呼叫方不得不處理錯誤,比 any 裸奔穩得多。
常見問題
Q1:類型體操值得花時間學嗎?
如果你寫業務 CRUD,掌握條件類型、infer、映射類型、模板字面量這四類,足以應付 90% 的場景。真正的「體操題」更多是訓練思維。別為了炫技把團隊程式碼寫成天書——可讀性也是類型的一部分。
Q2:interface 和 type 現在還有區別嗎?
日常用幾乎可互換。區別主要在:介面可宣告合併(同名下合併),類型別名能用聯合、條件、映射。個人習慣:描述「物件形狀」用 interface,做「類型變換」用 type。
Q3:要不要全員開啟 strict 模式?
強烈建議。strict: true 一次性打開 noImplicitAny、strictNullChecks 等一攬子檢查。短期會多出不少報錯要修,長期能消滅一整類 undefined is not a function。
Q4:泛型約束 extends 和繼承是一回事嗎?
不是。T extends Foo 在這裡表示「T 必須能賦值給 Foo」,和物件導向的 extends 是巧合同名。把它讀成「受限於」更不容易迷糊。
工具推薦
寫類型、和 JSON 打交道時,下面幾個 ToolsKu 工具能省不少事:
- JSON → TypeScript — 粘一段樣本 JSON,自動推斷 interface
- 通用程式碼格式化器 — 順手把類型定義排整齊
- 正則大全 — 模板字面量類型裡寫正則前先在這裡驗證
類型系統的盡頭不是「讓編譯器閉嘴」,而是「讓它在你犯錯之前開口」。把類型當同事,而不是當監工。
本站提供瀏覽器本地工具,免註冊即可試用 →