TypeScript – 測試與型別驗證
Utility Type 單元測試
簡介
在大型前端或 Node.js 專案中,型別安全是維持程式碼品質的關鍵。TypeScript 提供了許多 Utility Type(例如 Partial<T>、Pick<T>、Record<K,T> 等)能夠在開發階段快速產生衍生型別,減少手寫冗長的介面定義。然而,這些自動產生的型別如果寫錯,編譯器往往只能在使用時才會拋出錯誤,導致問題潛伏在測試階段才被發現。
將 Utility Type 與單元測試結合,不僅可以驗證型別的正確性,也能在程式執行時捕捉到不符合預期的結構。本文將說明如何在 Jest(或 Vitest、Mocha)中撰寫 Utility Type 單元測試,讓型別驗證成為開發流程的一部份,提升程式碼的可維護性與安全性。
核心概念
1. 為什麼要測試型別?
- 提前發現錯誤:Utility Type 只在編譯期運作,若使用者自行組合錯誤,編譯器有時無法完整捕捉。測試能在執行期驗證實際資料結構。
- 文件化契約:測試案例即是型別契約的活文件,其他開發者閱讀測試即可快速了解型別的使用限制。
- 防止回歸:未來改動 Utility Type 或底層介面時,測試會立即失敗,提醒開發者調整相依程式。
2. 常見的 Utility Type
| Utility Type | 功能說明 |
|---|---|
Partial<T> |
將所有屬性變成可選 |
Pick<T, K> |
從 T 中挑選部分屬性 K |
Omit<T, K> |
從 T 中排除屬性 K |
Record<K,T> |
建立以 K 為鍵、T 為值的物件型別 |
Required<T> |
把所有可選屬性變為必填 |
Readonly<T> |
讓物件屬性成為唯讀 |
3. 測試型別的技巧
- 使用
as const讓測試資料保持字面量型別,避免被寬鬆推斷。 - 搭配
expectType<T>()(由tsd或expect-type套件提供)在編譯期斷言型別是否相符。 - 結合
jest的toMatchObject,在執行期檢查結構是否符合預期。
註:本文以
jest+ts-jest為例,若使用vitest或mocha,概念相同,只是設定略有差異。
程式碼範例
範例 1:測試 Partial<T>
// user.ts
export interface User {
id: number;
name: string;
email: string;
}
// user.test.ts
import { User } from "./user";
type PartialUser = Partial<User>;
test("Partial<User> 只允許部分欄位", () => {
const data: PartialUser = { name: "Alice" }; // ✅ 只提供 name
// @ts-expect-error id 必須是 number
const wrong: PartialUser = { id: "not-number" };
expect(data).toMatchObject({ name: "Alice" });
});
- 說明:
Partial<User>讓所有屬性變為可選;編譯期使用@ts-expect-error確認錯誤會被捕捉。
範例 2:測試 Pick<T, K> 與 Omit<T, K>
// product.ts
export interface Product {
sku: string;
title: string;
price: number;
description?: string;
}
// 只取出 sku 與 price
type ProductInfo = Pick<Product, "sku" | "price">;
// 移除 description
type ProductCore = Omit<Product, "description">;
test("Pick 與 Omit 的型別正確性", () => {
const info: ProductInfo = { sku: "A001", price: 199 };
// @ts-expect-error 缺少 sku
const missing: ProductInfo = { price: 199 };
const core: ProductCore = { sku: "A001", title: "筆記本", price: 199 };
// @ts-expect-error description 不存在於 ProductCore
const extra: ProductCore = { sku: "A001", title: "筆記本", price: 199, description: "好用" };
expect(info).toMatchObject({ sku: "A001", price: 199 });
expect(core).toMatchObject({ sku: "A001", title: "筆記本", price: 199 });
});
- 說明:透過
Pick只保留需要的欄位,Omit則排除不必要的欄位;測試同時驗證正確與錯誤情況。
範例 3:測試 Record<K,T> 的鍵值型別
// config.ts
export type Env = "dev" | "staging" | "prod";
export interface Config {
apiUrl: string;
timeout: number;
}
// 建立每個環境的設定物件
type EnvConfig = Record<Env, Config>;
const CONFIG: EnvConfig = {
dev: { apiUrl: "http://localhost:3000", timeout: 5000 },
staging: { apiUrl: "https://stg.example.com", timeout: 8000 },
prod: { apiUrl: "https://api.example.com", timeout: 10000 },
};
test("Record<Env, Config> 必須包含所有環境", () => {
// @ts-expect-error 缺少 staging
const incomplete: EnvConfig = {
dev: { apiUrl: "", timeout: 0 },
prod: { apiUrl: "", timeout: 0 },
};
expect(CONFIG.dev.apiUrl).toContain("localhost");
expect(CONFIG.prod.timeout).toBe(10000);
});
- 說明:
Record確保每個鍵(此例為環境)都有對應的值;測試驗證缺漏鍵時會在編譯期報錯。
範例 4:結合 expect-type 斷言
// 安裝: npm i -D expect-type
import { expectType } from "expect-type";
type ReadonlyUser = Readonly<User>;
test("Readonly<User> 為唯讀型別", () => {
const user: ReadonlyUser = { id: 1, name: "Bob", email: "bob@example.com" };
// 編譯期斷言:屬性不可寫
// @ts-expect-error 嘗試寫入會錯誤
user.name = "Alice";
// 透過 expect-type 確認型別
expectType<ReadonlyUser>(user);
});
- 說明:
expect-type在編譯期就能斷言型別,配合 Jest 測試流程,能同時檢查執行期與型別正確性。
範例 5:測試 Required<T> 與 Partial<T> 混用
type UpdateUserPayload = Partial<User> & Required<Pick<User, "id">>;
test("Update payload 必須包含 id,其餘欄位可選", () => {
const payload: UpdateUserPayload = { id: 10, email: "new@example.com" };
// @ts-expect-error 缺少 id
const invalid: UpdateUserPayload = { email: "no-id@example.com" };
expect(payload.id).toBe(10);
expect(payload.email).toBe("new@example.com");
});
- 說明:透過
Required<Pick<...>>把關鍵欄位強制必填,同時保留其他欄位的可選性,這在 API 更新請求中非常常見。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
過度依賴 any |
為了讓測試跑過,直接把型別寫成 any,失去型別保護。 |
使用 unknown + 型別斷言,或在測試檔案中 嚴格 開啟 noImplicitAny。 |
| 測試資料過於寬鬆 | 用 as any 或 as unknown as T 直接套用,測試無法捕捉錯誤。 |
以 字面量 (as const) 定義測試資料,讓 TypeScript 推斷最精確的型別。 |
忽略 @ts-expect-error |
未在測試中加入預期錯誤,導致錯誤被忽略。 | 在每個故意錯誤的行前加入 // @ts-expect-error,確保編譯器真的產生錯誤。 |
| 忘記測試負面情況 | 只測試「正確」的型別組合,實務上常會傳入不完整或多餘欄位。 | 撰寫 negative 測試,驗證錯誤路徑會被正確捕捉。 |
| 未同步更新型別與測試 | 改變介面後忘記更新相關測試,導致測試失效或偽陽性。 | CI 中加入 typecheck 步驟,確保型別變動會直接影響測試結果。 |
最佳實踐:
- 保持測試與型別同步:每次修改介面或 Utility Type 時,立即更新對應的測試檔案。
- 使用
tsd或expect-type:在純型別檢查的情境下,這兩個套件提供更直觀的斷言語法。 - 將型別測試納入 CI:在
npm test前執行tsc --noEmit,確保編譯期錯誤不會被忽略。 - 把測試資料抽離成 fixture:重複使用的測試物件放在
__fixtures__目錄,維護成本降低。
實際應用場景
API 請求/回應的型別驗證
前端與後端約定的 DTO(Data Transfer Object)往往使用Pick、Omit或Partial產生子型別。透過單元測試可以確保每個端點回傳的結構與型別契約一致。表單狀態管理
使用Partial<FormValues>來描述尚未填寫完成的表單,測試可以驗證在不同階段(如draft、submitted)型別的變化是否符合預期。多語系資源檔
Record<Locale, Translation>常用於 i18n。測試保證所有語系都有完整鍵值,避免因缺少翻譯導致執行時錯誤。Redux / Zustand 狀態切片
Readonly<State>防止狀態被直接修改,測試可確保 reducer 回傳的物件保持只讀屬性。插件或第三方套件的型別擴充
例如在styled-components中使用ComponentProps<typeof MyComponent>搭配Partial產生可選屬性,測試可驗證擴充後的型別仍然正確。
總結
Utility Type 為 TypeScript 開發者提供了 高效、可組合 的型別工具,但其「自動」的特性也增加了型別錯誤潛伏的風險。透過 單元測試:
- 能在編譯期與執行期雙重保護型別安全
- 讓型別契約以可執行的範例形式呈現,降低團隊溝通成本
- 防止因介面變更而產生的回歸問題
本文示範了五個實用範例,涵蓋 Partial、Pick、Omit、Record、Readonly 等常見 Utility Type,並提供了 常見陷阱 與 最佳實踐。將這些測試模式納入日常開發與 CI 流程,能讓 TypeScript 專案在規模擴大時仍保持高度的型別可靠性與可維護性。祝你在 TypeScript 的型別世界裡玩得開心,也寫出更安全、更穩定的程式碼!