🎯 什麼情境該想到我
當你猶豫「這個型別要從既有型別挖出來(Pick/keyof/typeof),還是老實再寫一份?」的時候。
⚙️ 怎麼用(步驟 / 公式)
先認識工具
| 語法 | 做什麼 |
|---|---|
keyof T | 把 T 的 key 抽成聯集 |
typeof v | 從值取出它的型別 |
T[K] | 索引存取:取出某個屬性的型別 |
Pick<T, K> / Omit<T, K> | 挑出/排除某些屬性 |
interface A extends B | 繼承 |
⭐ 判準:兩邊是不是同一個關注點?
該推導 —— 共用同一個關注點
const albumTypes = { CD: "cd", VINYL: "vinyl" } as const;
type AlbumType = (typeof albumTypes)[keyof typeof albumTypes];值和型別本來就是同一件事的兩面。解耦的話你要維護兩份會走鐘的來源。
該解耦 —— 關注點不同
// ❌ 誘人但耦合:UI 元件綁死資料層的型別
type AvatarImageProps = Pick<User, "imageUrl" | "name">;
// ✅ 各自寫清楚
type AvatarImageProps = { imageUrl: string; name: string };User 是資料型別、AvatarImage 是 UI 元件——責任不同、改變的理由也不同。用 Pick 之後,這個元件不只依賴 User 的形狀,還依賴它存在於 db.ts 這件事。
★ 核心判準:這個型別是「契約」還是「衍生」
| 契約(contract) | 衍生(derived) | |
|---|---|---|
| 誰依賴它 | ★ 外部(別的模組、別的團隊、別的服務) | 只有自己 |
| 改動的意義 | ★ breaking change,要有意識地做 | 跟著來源走就好 |
| 該怎麼寫 | ★ 明確標註 | ★ 從來源推導 |
| 例子 | 公開 API 的參數與回傳、跨服務的 DTO | 內部的中間型別、元件的 props |
★ 一句話:「改了實作,這個型別跟著變,是我要的嗎?」
是 → 推導;不是(那會默默破壞下游)→ 明寫。
★ 三個「應該推導」的場景
① 從實作推導列舉(見 工具-satisfies與型別標註)
const routes = { home: "/", admin: "/admin" } satisfies Record<string, string>;
type RouteName = keyof typeof routes; // ★ 加路由自動更新,不用維護兩份② 從 schema 推導型別(★ 實務上最有價值的一個)
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0),
});
type User = z.infer<typeof UserSchema>; // ★ 型別從驗證器推導
// ★ 一份定義,同時給「執行期驗證」和「編譯期型別」
const user = UserSchema.parse(await res.json());★ 這解決了 TS 最根本的弱點:編譯期型別在執行期不存在。
手寫type User+ 手寫驗證 = 兩份會不同步的定義;從 schema 推導 = 一份定義,永遠同步。
同理:sqlc(從 SQL 產型別)、OpenAPI codegen、Prisma、GraphQL codegen。
③ 從函式推導
type Params = Parameters<typeof myFunc>;
type Result = Awaited<ReturnType<typeof fetchUser>>;★ 三個「應該明寫」的場景
① 公開函式的簽章
// ❌ 讓回傳型別被推導 → 改實作可能意外改變公開 API
export function getUser(id: string) {
return { id, name: "x" }; // ★ 多加一個內部欄位就變成 API 的一部分
}
// ✅ 明寫契約
export function getUser(id: string): User { ... }② 需要隱藏實作細節時 —— 標註成介面,讓呼叫端不能依賴你多回傳的東西。
③ ★ 讓型別錯誤出現在「對的地方」
// ❌ 不標註:錯誤出現在「使用的地方」,離源頭很遠
const config = { retries: "3" };
doWork(config); // ★ 這裡才報錯
// ✅ 標註:錯誤出現在「定義的地方」
const config: Config = { retries: "3" }; // ★ 這裡就報錯★ 標註的位置決定了錯誤訊息的位置——這是很實用的一條。
衍生型別的工具(不要複製貼上)
type PublicUser = Omit<User, "password">;
type UserUpdate = Partial<Omit<User, "id">>;
type UserCreate = Pick<User, "email" | "password">;
type UserId = User["id"];★ 但衍生也會過頭。
Partial<Omit<Pick<User,"a"|"b">,"c">>沒有人看得懂,
hover 出來的提示也是一大坨。超過兩層就給它一個名字。
★ 和 Go 的對照
Go 沒有型別層的運算——沒有 Omit、Partial、keyof。對應做法是定義多個 struct(手動維護、會不同步),
或用 go:generate 產生(sqlc、oapi-codegen)。
★ 和 TS 的 z.infer 是同一個問題的兩種解法。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- ★ 公開 API 一定要明寫。被推導的回傳型別會讓「內部重構」意外變成 breaking change。
- ★ 過度衍生會讓型別無法閱讀。三層以上的
Partial<Omit<...>>要拆開命名。 - ★ 型別關聯太緊也是一種耦合。
type B = A讓 A 改動直接波及 B——
如果 B 在語義上是獨立的概念(只是「現在剛好一樣」),就該分開寫。
判準:「A 改了,B 應該跟著改嗎?」 應該 → 關聯;不一定 → 分開。 z.infer的代價是執行期的驗證成本。熱路徑上的大物件驗證要量。- 推導出來的型別出現在錯誤訊息裡會很長,會讓錯誤難讀、編譯變慢。
- 不要為了「少打字」而推導。可讀性與契約的穩定性遠比打字量重要。
- Go 沒有這些,跨語言的共用型別要靠 codegen 或 schema(Protobuf、OpenAPI)。
🔗 相關工具
- 工具-satisfies與型別標註 —— 檢查形狀又保留推導
- 工具-as-const與推論寬窄 —— 控制推論寬窄
- 工具-判別聯集 —— 衍生型別常用在聯集上
- 工具-tsconfig基準設定 —— strict 是前提
- 工具-資料編碼與演進 —— 跨服務的型別要靠 schema 而非語言型別
- 工具-讓LLM產出可驗證的產物 —— 同樣是「一份 schema 兩邊用」的思路
- Total TypeScript Essentials