🎯 什麼情境該想到我

當你猶豫「這個型別要從既有型別挖出來(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)。

🔗 相關工具