TypeScript 的型別檢查依據什麼原則?「結構型別」是什麼意思?
和 到底該用哪個?
泛型在參數化什麼? 要怎麼讀?
標記聯集、、utility types 這些工具各自解決什麼實際問題?
用了三個月 TypeScript 的人,多半把型別當成「寫完程式後補的標註」, 遇到紅線就想辦法讓它消失(必要時 )。 這一章要完成一個視角升級:型別是建模工具—— 先用型別把「什麼是合法的資料與操作」描述出來,讓編譯器替你守住這個模型。 第 5 章結尾的「讓不可能的狀態無法表達」就是這個視角的實戰預告。
型別在做什麼:把值分成集合
一個型別就是一群值的集合: 是所有字串、 是只有兩個成員的集合、 是空集合。 型別檢查回答的問題永遠是同一個: 「這裡出現的值,保證屬於這裡要求的集合嗎?」 賦值、傳參數、回傳,全部是這一個問題的變形。
用集合觀點,很多語法瞬間變得可推理:
(聯集):兩個集合的併集——值可能是 A 也可能是 B, 所以只能安全使用兩者共有的成員,用其他成員前要先收窄(6.4 節)。
(交集):同時滿足兩者——物件要同時具備兩邊的欄位。
比 大: 小集合可以放進大集合的位置,反之不行。 這就是為什麼函式參數宣告成字面量聯集後,亂傳字串會被擋下。
而 TypeScript 的價值主張,說穿了是把一類錯誤的發現時間往前搬: 執行期的 ,變成編譯期(其實是「打字當下」)的紅線。 附帶的紅利在日常工作裡甚至更有感—— 型別是不會過期的文件(註解會騙人,型別不會,因為它被強制執行)、 是重構的安全網(改個欄位名,所有沒改到的使用點立刻亮紅)、 也是 IDE 自動補全的資料來源。
TypeScript 的型別只存在於編譯期,編譯成 JavaScript 後全部消失。 這帶來一個工作上必須內化的推論: 型別不會在執行期保護你。你宣告 API 回應是 , 只是「向編譯器承諾」,後端多回一個欄位、少一個欄位,執行期不會有任何警報。 所以資料跨越系統邊界(API 回應、、)時, 需要執行期驗證(第 7 章會用 zod 示範)。 邊界之內,才是型別系統說了算的世界。
結構型別:看形狀,不看名字
TypeScript 與 Java/C# 最根本的差異:型別相容性 只看結構(有哪些欄位、什麼型別),不看名字或宣告關係。
interface Point { x: number; y: number }
function draw(p: Point) { /* ... */ }
const spot = { x: 1, y: 2, label: 'home' };
draw(spot); // OK!spot 沒有宣告自己「是」Point,
// 但它的形狀滿足 Point 的要求(多的欄位不礙事)
從沒說過自己實作了 ——不需要。 形狀對了就相容,這讓 TypeScript 能自然地描述「本來就存在的 JavaScript 程式碼」, 也解釋了為什麼你可以把後端回應直接餵給任何「形狀合得上」的函式。
但有一個著名的特例會讓新手以為規則矛盾了:
draw({ x: 1, y: 2, label: 'home' });
// ^^^^^ 錯誤:物件字面量只能指定已知屬性
同樣的物件,先存變數再傳可以,直接以字面量傳卻報錯? 這是刻意設計的多餘屬性檢查(excess property check): 「當場寫出來的字面量」多出的欄位不可能有其他用途,幾乎可以肯定是拼錯欄位名, 所以 TypeScript 特別從嚴。理解成「字面量享受拼字檢查特權」即可。
type 與 interface:怎麼選
先說結論:兩者在「描述物件形狀」這個用途上幾乎可以互換, 多數團隊的選擇是慣例問題而不是對錯問題。有實質差異的地方:
| interface | type | |
|---|---|---|
| 描述物件/函式形狀 | 可以 | 可以 |
| 聯集、交集、字面量、mapped types | 不行 | 只有 type 能寫 |
| 擴充 | (錯誤訊息較友善) | 交集 |
| 同名再宣告 | 自動合併(declaration merging) | 重複宣告是錯誤 |
元件 props、公開 API 的物件形狀:(慣例常見、 擴充自然)。
聯集()、字面量()、從其他型別演算出來的型別: 只能用 ,這也是實戰中 越用越多的原因。
declaration merging 幾乎只在一種場景有價值:擴充第三方套件的型別 (例如幫 的 加欄位)。自己的程式碼不要依賴它。
最重要的一條:跟隨團隊現有慣例,一致性比個人偏好值錢。
聯集與收窄:讓編譯器跟著你的 if 走
聯集型別(union)表達「這個值是其中之一」。 關鍵技能是收窄(narrowing):用執行期檢查說服編譯器「在這個分支裡,值只可能是某幾種」。
function formatId(id: string | number): string {
// 這裡 id 是 string | number,只能用兩者共有的操作
if (typeof id === 'number') {
return id.toFixed(0); // 這個分支裡 id: number
}
return id.toUpperCase(); // if 沒中,所以這裡 id: string
}
TypeScript 會沿著控制流程追蹤每個分支裡型別的變化—— 這叫 control flow analysis,是它最聰明的部分。常用的收窄手段: (原始型別)、 (class)、 (依欄位存在區分)、 /truthiness 檢查(去掉 ), 以及對字面量欄位的相等比較——這最後一種威力最大,值得獨立成節。
標記聯集:實戰中最重要的型別模式
第 5 章已經給過動機(三顆布林的碎片化狀態),現在補上完整的機制。 標記聯集(discriminated union)=聯集的每個成員都帶著 同一個「標記欄位」(通常叫 、 或 ), 其值是互不重疊的字面量:
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function render(state: FetchState<User[]>) {
switch (state.status) {
case 'loading': return <Spinner />;
case 'success': return <UserList users={state.data} />;
// ^^^^ 只有這個分支拿得到 data
case 'error': return <Alert message={state.error} />;
case 'idle': return null;
}
}
檢查標記欄位( 或 )時, 編譯器自動把整個物件收窄成對應的成員—— 「檢查狀態」與「取得該狀態才有的資料」變成同一個動作, 「忘記檢查就取用」從 runtime bug 變成編譯錯誤。
再加一個免費的保險——窮盡檢查(exhaustiveness check):
default: {
// 若上面的 case 涵蓋了所有成員,這裡的 state 已被收窄到 never(空集合)
const unreachable: never = state;
throw new Error(`未處理的狀態: ${unreachable}`);
}
半年後有人幫 加了 成員, 所有漏掉這個 case 的 會同時亮起編譯錯誤, 一個都跑不掉——這就是「型別是重構安全網」最具體的體現。 公司程式碼裡的 Redux action、WebSocket 訊息、表單流程狀態, 清一色是這個模式,認得它等於拿到一把萬用鑰匙。
泛型:型別層級的參數
泛型是多數人「看得懂但寫不出來」的分水嶺。突破口是理解它的動機—— 看一個最小的例子,取陣列第一個元素:
// 方案一:寫死型別 → 每種型別複製一份,違反 DRY
function firstOfStrings(arr: string[]): string | undefined { return arr[0]; }
function firstOfUsers(arr: User[]): User | undefined { return arr[0]; }
// 方案二:用 any → 型別資訊在出口蒸發,等於自廢武功
function firstAny(arr: any[]): any { return arr[0]; }
const u = firstAny(users); // u: any —— 從這行起編譯器幫不了你了
// 方案三:泛型 → 「進來什麼,出去就是什麼」這個【關係】被保留下來
function first<T>(arr: T[]): T | undefined { return arr[0]; }
const user = first(users); // user: User | undefined(自動推斷,不用手寫)
const name = first(['a', 'b']); // name: string | undefined
函式參數把「值」參數化:同一段邏輯適用於不同的值。 泛型把「型別」參數化:同一段邏輯適用於不同的型別, 並且把輸入與輸出型別之間的關係保留下來(進來 ,出去 )。 讀泛型的口訣:尖括號裡是宣告(引入一個型別變數), 後面出現的同名字母是使用。呼叫端通常不需要手動指定—— 編譯器會從實際傳入的值推斷 是什麼。
泛型預設對 一無所知,所以 上什麼操作都不能做。 需要「T 至少長什麼樣」時,用 加上約束:
// K extends keyof T:K 必須是「T 的欄位名」這個集合的成員
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Amy' };
pluck(user, 'name'); // 回傳型別自動是 string
pluck(user, 'email'); // 編譯錯誤:'email' 不是 user 的欄位
短短一行裡有三個可以帶走的零件: (把 T 的欄位名變成字面量聯集 )、 約束(限制型別參數的範圍)、 (索引存取:欄位 K 的型別)。 它們組合出「傳錯欄位名直接編譯錯誤、回傳型別隨欄位自動變化」—— 這正是公司程式碼裡那些「神奇地知道你要什麼」的工具函式的原理。
實戰中你天天在消費泛型而不自知: 、、 (它就是 )、第 4 章的 、 以及所有 API 包裝層的 。 泛型不是進階炫技,是「不複製貼上、也不丟失型別」的唯一路徑。
讓值成為型別的唯一來源:typeof、keyof、as const
實務上常見的重複:同一份「選項清單」,值寫一遍、型別再寫一遍,兩邊要人工同步:
// 反模式:值與型別各自維護,加一個尺寸要改兩個地方
const SIZES = ['sm', 'md', 'lg'];
type Size = 'sm' | 'md' | 'lg';
TypeScript 提供從「值」推導「型別」的工具鏈,讓事實只有一份:
const SIZES = ['sm', 'md', 'lg'] as const;
// as const:型別從 string[] 收緊為 readonly ['sm', 'md', 'lg']
type Size = (typeof SIZES)[number]; // 'sm' | 'md' | 'lg'(自動推導)
const BUTTON_TONE = { neutral: 'gray', danger: 'red' } as const;
type Tone = keyof typeof BUTTON_TONE; // 'neutral' | 'danger'
拆解一下這串咒語: 告訴編譯器「這個值永遠不變, 請用最窄的字面量型別記住它」;(型別語境版)取出值的靜態型別; / 再從中萃取成員。 之後新增尺寸只改 一處,所有相關型別自動更新—— 單一事實來源原則在型別層的實踐。 (順帶:這一套通常比 更受現代 TS 團隊青睞—— 不引入額外的執行期結構,跟字面量聯集無縫相容。)
Utility Types:型別的標準函式庫
TypeScript 內建一批「吃型別、吐型別」的工具。先看最常用的四個在實戰裡的長相:
interface User { id: string; name: string; email: string; role: Role }
// 建立時還沒有 id(由後端配發):
type NewUser = Omit<User, 'id'>;
// 編輯表單只允許改部分欄位:
type UserPatch = Partial<Pick<User, 'name' | 'email'>>;
// 以 id 索引的查找表:
type UsersById = Record<string, User>;
重點不是背表,而是看穿它們不是魔法——自己寫一次 就除魅了:
// mapped type:對 T 的每個欄位 P 逐一改造
type MyPartial<T> = {
[P in keyof T]?: T[P]; // in keyof T = 走訪所有欄位;? = 加上可選
};
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
讀作「對 T 的每個欄位名 P」——型別層的 迴圈。 懂了這個,讀公司程式碼裡自訂的工具型別(以及 這類組合) 就只是普通的閱讀,不再是黑盒。
全部變可選(patch 更新); 反向; / 取/去欄位(從完整模型裁出子集); 鍵值表;/ 從函式反推型別(跟著既有函式走,不重複宣告); / 對聯集做減法/交集。 共同精神:從既有型別演算出新型別,而不是再手寫一份。
any、unknown、never:三個特殊集合
:「請編譯器閉嘴」。它不只是「任意值」, 而是雙向免檢——什麼都能塞給它、它也能塞給任何地方, 而且會傳染:一個 流過的每個變數都跟著失去保護。 每寫一個 ,就是在型別安全網上剪一個洞。
:「任意值」的安全版。什麼都能放進來, 但用之前必須收窄——編譯器強迫你先檢查。 「還不知道是什麼」的值( 的結果、 的 e、 未驗證的外部輸入)一律用它:
const parsed: unknown = JSON.parse(raw); if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) { // 收窄之後才能使用 —— any 會讓你跳過這一步,然後在執行期爆炸 }:空集合,「不可能有值」。兩個實戰角色: 窮盡檢查的哨兵(6.5 節)、以及永不回傳的函式 (/無限迴圈)的回傳型別。
最後是 型別斷言。它的語意是「編譯器,你先讓開,我對這個值的了解比你多」—— 斷言不做任何轉換或檢查,只是單方面壓過編譯器的判斷。 正當用途確實存在(DOM API 的泛化回傳、測試裡構造假資料), 但每個 都該視為一句需要理由的宣示。 特別警惕連環斷言 :它能把任何東西「變成」任何型別, 出現時幾乎總是設計出了問題。 優先順序永遠是:收窄 > 型別守衛 > 斷言。
型別=值的集合;檢查=集合相容性;聯集是併集、交集要同時滿足;小集合可放進大集合。
TypeScript 是結構型別:形狀對就相容;物件字面量另享「多餘屬性檢查」。
type 能表達聯集與型別演算,interface 有 extends 與宣告合併;物件形狀兩者皆可,跟隨團隊慣例。
標記聯集+收窄+窮盡檢查=「不可能的狀態無法表達、漏處理的分支編譯期現形」。
泛型參數化型別並保留輸入輸出的關係; 加約束;/ 精準描述物件操作。
++ 讓值成為型別的單一事實來源;utility types 是型別演算的標準庫,本質是 mapped types。
不明的值用 不用 ; 是最後手段。