TypeScript 課程:型別推論與型別保護 – in 運算子型別保護
簡介
在 JavaScript 中,物件的屬性往往是動態決定的,這讓開發者在編寫大型程式時容易遭遇 執行時錯誤(例如存取不存在的屬性)。TypeScript 透過 型別保護(type guarding),在編譯階段就能幫助我們排除這類問題。
in 運算子是 TypeScript 提供的 型別保護 手段之一,它可以在 條件分支 中縮窄(narrow)變數的型別,讓編譯器知道某個屬性一定存在。掌握 in 的使用方式,不僅能提升程式的安全性,還能讓 IDE 提供更精確的自動完成與錯誤提示,對於 初學者 與 中階開發者 都是必備的技巧。
本文將以 實務範例 為主軸,說明 in 運算子如何在型別推論與型別保護中發揮作用,並探討常見陷阱與最佳實踐,最後提供幾個在真實專案中常見的應用情境。
核心概念
1. in 運算子是什麼?
in 是 JavaScript 的原生運算子,用來檢查 屬性名稱 是否存在於物件或其原型鏈上。
const obj = { name: "Alice", age: 30 };
"name" in obj; // true
"email" in obj; // false
在 TypeScript 中,in 同時具備 型別保護 的功能:當條件判斷為 true 時,編譯器會把左側的屬性視為 必定存在,進而縮窄變數的聯合型別 (union type)。
2. 為什麼需要 in 來做型別保護?
假設我們有兩個介面:
interface Dog {
kind: "dog";
bark(): void;
weight: number;
}
interface Bird {
kind: "bird";
fly(): void;
wingSpan: number;
}
如果有一個參數的型別是 Dog | Bird,直接呼叫 bark() 會產生錯誤,因為 編譯器無法確定 真正的型別。使用 in 可以這樣做:
function handleAnimal(animal: Dog | Bird) {
if ("bark" in animal) {
// 在這個區塊內,animal 被縮窄為 Dog
animal.bark();
console.log(`Weight: ${animal.weight}`);
} else {
// 這裡則是 Bird
animal.fly();
console.log(`Wing span: ${animal.wingSpan}`);
}
}
當條件 ("bark" in animal) 為 true 時,TypeScript 會把 animal 的型別 從 Dog | Bird 縮窄成 Dog,因此可以安全地存取 bark、weight 等屬性。
3. in 與 keyof 的結合
如果你想要寫一個 通用的型別保護函式,可以利用 keyof 取得所有可能的屬性鍵,然後配合 in:
function hasKey<O extends object, K extends PropertyKey>(
obj: O,
key: K
): obj is O & Record<K, unknown> {
return key in obj;
}
// 使用範例
type Person = { name: string; age: number };
type Car = { brand: string; speed: number };
function logInfo(item: Person | Car) {
if (hasKey(item, "name")) {
// item 被視為 Person
console.log(`Person: ${item.name}, ${item.age}`);
} else {
// item 被視為 Car
console.log(`Car: ${item.brand}, ${item.speed}km/h`);
}
}
這裡的 hasKey 回傳 型別謂詞 (obj is O & Record<K, unknown>),讓 TypeScript 能在呼叫處自動完成型別縮窄。
4. in 也能保護 字串字面值聯合型別
當你有一個字串聯合型別,想要根據字串的值分支時,in 同樣適用:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
function area(s: Shape): number {
if ("radius" in s) {
// s 為 circle
return Math.PI * s.radius ** 2;
}
if ("side" in s) {
// s 為 square
return s.side ** 2;
}
// 只能是 rectangle
return s.width * s.height;
}
此技巧比使用 switch (s.kind) 更直觀,尤其當屬性名稱本身就能唯一辨識型別時。
5. in 與 可選屬性 (optional properties)
對於具有可選屬性的介面,in 能協助檢查屬性是否真的被賦值:
interface Config {
url: string;
timeout?: number; // 可選
}
function fetchData(cfg: Config) {
const opts: RequestInit = { method: "GET" };
if ("timeout" in cfg) {
// timeout 已被提供
opts.signal = AbortSignal.timeout(cfg.timeout!);
}
// ...
}
注意:即使屬性值是 undefined,只要屬性鍵在物件上(即使值為 undefined),in 仍會回傳 true。因此在可選屬性的情境下,若需要判斷「是否真的有值」應再檢查 !== undefined。
程式碼範例
範例 1:基本的 in 型別保護
interface Cat {
kind: "cat";
meow(): void;
lives: number;
}
interface Fish {
kind: "fish";
swim(): void;
waterTemp: number;
}
function interact(pet: Cat | Fish) {
if ("meow" in pet) {
// pet 被縮窄為 Cat
pet.meow();
console.log(`Lives left: ${pet.lives}`);
} else {
// pet 為 Fish
pet.swim();
console.log(`Water temperature: ${pet.waterTemp}°C`);
}
}
重點:
"meow" in pet讓編譯器知道pet必定是Cat,因此可以安全呼叫meow()與lives。
範例 2:結合 keyof 實作通用型別保護
function hasProp<O extends object, K extends PropertyKey>(
obj: O,
prop: K
): obj is O & Record<K, unknown> {
return prop in obj;
}
type ApiResponse =
| { status: 200; data: string }
| { status: 404; error: string }
| { status: 500; retryAfter: number };
function handleResponse(res: ApiResponse) {
if (hasProp(res, "data")) {
console.log(`Success: ${res.data}`);
} else if (hasProp(res, "error")) {
console.warn(`Not found: ${res.error}`);
} else {
console.error(`Server error, retry after ${res.retryAfter}s`);
}
}
技巧:
hasProp透過 型別謂詞 讓呼叫端直接得到縮窄後的型別,省去重複寫if ("data" in res)的麻煩。
範例 3:使用 in 判斷可選屬性
interface UserOptions {
username: string;
email?: string; // 可選
isAdmin?: boolean; // 可選
}
function createUser(opts: UserOptions) {
const user = { id: Date.now(), ...opts };
if ("email" in opts) {
// email 屬性確實存在(即使值是 undefined)
console.log(`Send welcome email to ${opts.email}`);
}
// 若要檢查值是否真的有提供
if (opts.email !== undefined) {
console.log(`Valid email: ${opts.email}`);
}
// 判斷管理員權限
const isAdmin = "isAdmin" in opts ? !!opts.isAdmin : false;
console.log(`Is admin? ${isAdmin}`);
}
注意:
"email" in opts只保證屬性鍵存在,若要確保值非undefined,仍需額外檢查。
範例 4:in 與字串聯合型別的結合
type Notification =
| { type: "email"; to: string; subject: string }
| { type: "sms"; phone: string; message: string }
| { type: "push"; deviceId: string; payload: any };
function send(notif: Notification) {
if ("to" in notif) {
// email
console.log(`Sending email to ${notif.to}: ${notif.subject}`);
} else if ("phone" in notif) {
// sms
console.log(`Sending SMS to ${notif.phone}: ${notif.message}`);
} else {
// push
console.log(`Push to ${notif.deviceId}`);
}
}
實務觀點:當資料來源是外部 API,屬性名稱往往是唯一辨識型別的關鍵,此時使用
in可以寫出 更直觀且安全 的程式碼。
範例 5:在函式重載中搭配 in
function getValue(obj: { a: number } | { b: string }, key: "a"): number;
function getValue(obj: { a: number } | { b: string }, key: "b"): string;
function getValue(obj: any, key: "a" | "b"): any {
if (key in obj) {
return obj[key];
}
throw new Error(`Key ${key} not found`);
}
// 使用
const num = getValue({ a: 10 }, "a"); // num 為 number
const str = getValue({ b: "hello" }, "b"); // str 為 string
關鍵:在實作函式時,
in讓我們能安全地存取動態鍵,且不會破壞重載的型別推論。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 屬性名稱同時出現在多個型別 | 若聯合型別的成員都包含相同屬性,in 無法縮窄型別。 |
使用 kind discriminant 或結合 typeof、instanceof 進一步區分。 |
in 只檢查鍵是否存在,而非值是否為 undefined |
可選屬性若值為 undefined,仍會回傳 true。 |
在需要確保值存在時,額外檢查 obj[prop] !== undefined。 |
對於 null / undefined 使用 in 會拋錯 |
null 或 undefined 不是物件,會產生 runtime error。 |
先做 obj != null 的檢查,或使用 可選鏈結 (obj?.prop). |
| 在泛型中直接使用字面字串 | 若泛型參數不保證有該屬性,編譯器會報錯。 | 使用 型別謂詞 (obj is Record<K, unknown>) 或 keyof 限制泛型。 |
過度依賴 in,忽略 type guard 函式 |
直接寫大量 if ("x" in obj) 會降低可讀性。 |
把判斷抽成 可重用的型別保護函式(如 hasProp),提升維護性。 |
最佳實踐
- 優先使用 discriminated union(如
kind、type字段)配合switch,結構更清晰。 - 在需要動態屬性檢查時,才使用
in,並搭配 型別謂詞 讓編譯器自動縮窄。 - 避免在
null/undefined上直接使用in,先做 null 檢查或使用可選鏈結。 - 對於可選屬性,若只關心「是否被賦值」而非「是否存在鍵」時,使用
!== undefined。 - 將常用的
in判斷抽成工具函式,讓程式碼保持 DRY(Don't Repeat Yourself)。
實際應用場景
1. 前端表單資料驗證
在表單提交前,我們常需要根據欄位是否存在來決定驗證規則:
interface BaseForm { name: string; }
interface EmailForm extends BaseForm { email: string; }
interface PhoneForm extends BaseForm { phone: string; }
function validate(form: EmailForm | PhoneForm) {
if ("email" in form) {
// email 必填且格式正確
if (!/^\S+@\S+\.\S+$/.test(form.email)) {
throw new Error("Invalid email");
}
} else {
// phone 必填且只能是數字
if (!/^\d+$/.test(form.phone)) {
throw new Error("Invalid phone number");
}
}
}
2. 後端 API 回傳的多樣化結果
某些 REST API 會根據狀態碼回傳不同結構,使用 in 能在 服務端 或 前端 直接判斷:
type ApiResult =
| { status: 200; payload: { userId: string } }
| { status: 400; error: string }
| { status: 401; reason: "unauthenticated" | "expiredToken" };
function processResult(r: ApiResult) {
if ("payload" in r) {
console.log(`User ID: ${r.payload.userId}`);
} else if ("error" in r) {
console.warn(`Bad request: ${r.error}`);
} else {
console.error(`Auth issue: ${r.reason}`);
}
}
3. 動態插件系統
在大型應用中,插件可能會提供不同的 API。透過 in 可安全地呼叫插件提供的功能:
type Plugin =
| { init(): void; version: string }
| { render(container: HTMLElement): void; theme?: string };
function usePlugin(p: Plugin) {
if ("init" in p) {
p.init();
console.log(`Plugin version ${p.version}`);
} else {
const container = document.getElementById("app")!;
p.render(container);
if (p.theme) {
container.dataset.theme = p.theme;
}
}
}
4. 讀取設定檔或環境變數
設定檔中可能只包含部份欄位,使用 in 能在載入時自動補足預設值:
interface EnvConfig {
apiUrl: string;
timeout?: number;
debug?: boolean;
}
function loadConfig(raw: Partial<EnvConfig>): EnvConfig {
const defaults: Required<Pick<EnvConfig, "timeout" | "debug">> = {
timeout: 5000,
debug: false,
};
return {
apiUrl: raw.apiUrl,
timeout: "timeout" in raw ? raw.timeout! : defaults.timeout,
debug: "debug" in raw ? raw.debug! : defaults.debug,
};
}
總結
in運算子不僅是 檢查屬性是否存在 的工具,更是 型別保護 的關鍵利器。- 透過
in,TypeScript 能在條件分支中 自動縮窄聯合型別,讓開發者安全地存取屬性與方法。 - 結合
keyof、型別謂詞(type predicate)與 泛型,可打造通用且可重用的型別保護函式。 - 使用時要留意 可選屬性、null/undefined 的情況,並避免在所有成員皆擁有相同屬性時過度依賴
in。 - 在實務上,
in常用於 表單驗證、API 回應處理、插件系統、設定檔載入 等需要根據屬性存在與否決策的場景。
掌握 in 運算子的型別保護技巧,能讓你的 TypeScript 程式碼在 安全性、可讀性與維護性 上都有顯著提升。祝你在日常開發中玩得開心、寫得更好! 🚀