本文 AI 產出,尚未審核

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. 測試型別的技巧

  1. 使用 as const 讓測試資料保持字面量型別,避免被寬鬆推斷。
  2. 搭配 expectType<T>()(由 tsdexpect-type 套件提供)在編譯期斷言型別是否相符。
  3. 結合 jesttoMatchObject,在執行期檢查結構是否符合預期。

:本文以 jest + ts-jest 為例,若使用 vitestmocha,概念相同,只是設定略有差異。


程式碼範例

範例 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 anyas unknown as T 直接套用,測試無法捕捉錯誤。 字面量 (as const) 定義測試資料,讓 TypeScript 推斷最精確的型別。
忽略 @ts-expect-error 未在測試中加入預期錯誤,導致錯誤被忽略。 在每個故意錯誤的行前加入 // @ts-expect-error,確保編譯器真的產生錯誤。
忘記測試負面情況 只測試「正確」的型別組合,實務上常會傳入不完整或多餘欄位。 撰寫 negative 測試,驗證錯誤路徑會被正確捕捉。
未同步更新型別與測試 改變介面後忘記更新相關測試,導致測試失效或偽陽性。 CI 中加入 typecheck 步驟,確保型別變動會直接影響測試結果。

最佳實踐

  1. 保持測試與型別同步:每次修改介面或 Utility Type 時,立即更新對應的測試檔案。
  2. 使用 tsdexpect-type:在純型別檢查的情境下,這兩個套件提供更直觀的斷言語法。
  3. 將型別測試納入 CI:在 npm test 前執行 tsc --noEmit,確保編譯期錯誤不會被忽略。
  4. 把測試資料抽離成 fixture:重複使用的測試物件放在 __fixtures__ 目錄,維護成本降低。

實際應用場景

  1. API 請求/回應的型別驗證
    前端與後端約定的 DTO(Data Transfer Object)往往使用 PickOmitPartial 產生子型別。透過單元測試可以確保每個端點回傳的結構與型別契約一致。

  2. 表單狀態管理
    使用 Partial<FormValues> 來描述尚未填寫完成的表單,測試可以驗證在不同階段(如 draftsubmitted)型別的變化是否符合預期。

  3. 多語系資源檔
    Record<Locale, Translation> 常用於 i18n。測試保證所有語系都有完整鍵值,避免因缺少翻譯導致執行時錯誤。

  4. Redux / Zustand 狀態切片
    Readonly<State> 防止狀態被直接修改,測試可確保 reducer 回傳的物件保持只讀屬性。

  5. 插件或第三方套件的型別擴充
    例如在 styled-components 中使用 ComponentProps<typeof MyComponent> 搭配 Partial 產生可選屬性,測試可驗證擴充後的型別仍然正確。


總結

Utility Type 為 TypeScript 開發者提供了 高效可組合 的型別工具,但其「自動」的特性也增加了型別錯誤潛伏的風險。透過 單元測試

  • 能在編譯期與執行期雙重保護型別安全
  • 讓型別契約以可執行的範例形式呈現,降低團隊溝通成本
  • 防止因介面變更而產生的回歸問題

本文示範了五個實用範例,涵蓋 PartialPickOmitRecordReadonly 等常見 Utility Type,並提供了 常見陷阱最佳實踐。將這些測試模式納入日常開發與 CI 流程,能讓 TypeScript 專案在規模擴大時仍保持高度的型別可靠性與可維護性。祝你在 TypeScript 的型別世界裡玩得開心,也寫出更安全、更穩定的程式碼!