TYPESCRIPT CORE · 92
Object、Interface、Union:把 Domain Model 與狀態寫清楚
上一課只知道「外部資料未知」。這一課開始定義應用程式內部接受的合法資料形狀,讓不可能狀態更難被表示。
Learning outcomes
- 能用 object type / interface 描述 domain data。
- 能用 optional、readonly、literal union 表達 contract。
- 能用 discriminated union 建模 loading / success / error。
- 能解釋「型別表示合法狀態」與「runtime 驗證資料」的分工。
1. Domain model:Course 應該長什麼樣?
interface Course {
readonly id: number;
title: string;
score: number | null;
}
readonly id 表示 application code 不應重新指定 id;score 明確允許 null,而不是假裝每門課一定已經有成績。
2. Optional 跟 nullable 不是同一件事
type A = {
score?: number;
};
type B = {
score: number | null;
};
| 型別 | 意思 |
|---|---|
score?: number | property 可以根本不存在 |
score: number | null | property 必須存在,但 value 可能是 null |
3. Literal union:不要讓 status 是任意 string
type Status =
| "idle"
| "loading"
| "success"
| "error";
如果只是 string,"sucess" 這種 typo 也合法;literal union 把合法值集合縮小。
4. Discriminated union:讓不同狀態帶不同資料
type CourseState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "success"; data: Course }
| { kind: "error"; message: string };
現在 success 一定有 data,error 一定有 message。這比:
type BadState = {
loading: boolean;
data?: Course;
error?: string;
};
更不容易出現「loading=true、data 有值、error 也有值」這種矛盾狀態。
5. Project checkpoint:Typed Course API Client v2
let state: CourseState = { kind: "idle" };
async function refreshCourse(id: number) {
state = { kind: "loading" };
try {
const raw = await loadCourse(id);
// 下一課會真正 validate raw
state = {
kind: "success",
data: raw as Course
};
} catch (error) {
state = {
kind: "error",
message: "Unable to load course"
};
}
}
刻意留下的問題:
raw as Course 只是 type assertion,不是 validation。下一課就是把這個洞補起來。6. type vs interface:先會讀,不要陷入宗教戰爭
兩者在 object shape 上能力高度重疊。初學最重要的是能理解團隊 code,而不是先建立「永遠只用哪一種」的規則。
Knowledge check
- 為什麼
score?: number與score: number | null不等價? - 把
CourseState增加empty狀態。 - 列出
BadState能表示但其實不合理的兩種組合。 - 解釋
as Course為什麼不能取代 runtime validation。