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

標準庫裡的 ExtractExclude 就是條件類型寫的。試著自己實作一遍,比看十遍文件都管用:

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、配置校驗時特別香——把「拼字錯誤」擋在編譯期。


五、遞歸類型

類型可以是遞歸的。配合 readonlyobject 判斷,能表達深層不可變結構:

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)

相比 assatisfies 是「校驗但不改寫」,安全得多。

與 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 一次性打開 noImplicitAnystrictNullChecks 等一攬子檢查。短期會多出不少報錯要修,長期能消滅一整類 undefined is not a function

Q4:泛型約束 extends 和繼承是一回事嗎?

不是。T extends Foo 在這裡表示「T 必須能賦值給 Foo」,和物件導向的 extends 是巧合同名。把它讀成「受限於」更不容易迷糊。


工具推薦

寫類型、和 JSON 打交道時,下面幾個 ToolsKu 工具能省不少事:


類型系統的盡頭不是「讓編譯器閉嘴」,而是「讓它在你犯錯之前開口」。把類型當同事,而不是當監工。

本站提供瀏覽器本地工具,免註冊即可試用 →

#TypeScript#类型系统#类型体操#泛型#前端工程