本文 AI 產出,尚未審核

TypeScript – 測試與型別驗證:型別測試(tsd / expect‑type)


簡介

在大型前端或 Node.js 專案中,型別安全往往是最能提升維護性與開發效率的保證。即使 TypeScript 已經在編譯階段幫我們捕捉大多數錯誤,仍有兩類情況會讓型別資訊「走失」:

  1. 第三方函式庫的型別宣告不完整或錯誤
  2. 在函式實作與呼叫端之間,型別推斷因條件分支或泛型過度抽象而失真

這時候,僅靠 tsc 的編譯結果已不足以驗證「程式碼的型別行為」是否符合預期。型別測試(type‑testing)提供了一種「在測試階段」斷言型別的手段,讓我們能在 CI 中自動檢查型別契約是否被破壞。本文將介紹兩個主流工具 tsdexpect‑type,說明它們的安裝、使用方式、常見陷阱與最佳實踐,並示範在真實專案中如何運用型別測試提升品質。


核心概念

1. 型別測試是什麼?

型別測試不是執行程式碼的單元測試,而是在編譯階段透過 TypeScript 的型別系統驗證「型別等價」或「型別子集」關係。簡單來說,我們寫一段 斷言,如果 TypeScript 在編譯時無法通過,測試即失敗。

重點:型別測試只會在 type‑checking 階段出錯,不會產生執行時例外。

2. 為什麼需要型別測試?

情境 只用 tsc 的缺點 型別測試的好處
第三方套件更新後型別宣告變動 只能在程式碼執行時才發現錯誤 CI 立即失敗,避免部署錯誤
公開 API 的型別介面需保持向下相容 手動比對型別檔案易遺漏 以程式化方式斷言相容性
複雜泛型工具函式的推斷結果 編譯器不一定提示不符合預期的型別 直接寫斷言,讓編譯器告訴你問題

3. 兩大工具概覽

工具 主要特點 典型使用情境
tsd 透過 .test-d.ts 檔案寫型別斷言,搭配 tsd CLI 執行。 檢查公開型別宣告、第三方套件的型別相容性
expect‑type 在 Jest、Vitest 等測試框架中使用 expectType<T>(value) 斷言,支援 type‑onlyexpect 風格。 與單元測試混合、在同一測試檔案中同時驗證執行結果與型別

程式碼範例

以下示範如何在同一個專案中同時使用 tsdexpect‑type。假設我們有一個簡單的函式庫 src/utils.ts

// src/utils.ts
export type User = {
  id: number;
  name: string;
  email?: string;
};

/**
 * 取得使用者名稱,若 name 為空字串則回傳 "Anonymous"
 */
export function getDisplayName(user: User): string {
  return user.name.trim() || "Anonymous";
}

/**
 * 合併兩個陣列,保留唯一值(使用 Set)
 */
export function mergeUnique<T>(a: T[], b: T[]): T[] {
  return Array.from(new Set([...a, ...b]));
}

3.1 使用 tsd 撰寫型別測試

  1. 安裝
npm i -D tsd
  1. test-d 目錄建立 .test-d.ts
// test-d/utils.test-d.ts
import { expectType, expectNever } from 'tsd';
import { getDisplayName, mergeUnique, User } from '../src/utils';

// ---------------------------------------------------
// 1. getDisplayName 必須回傳 string
// ---------------------------------------------------
expectType<string>(getDisplayName({ id: 1, name: 'Alice' }));

// ---------------------------------------------------
// 2. 當傳入的 name 為空字串,仍回傳 string
// ---------------------------------------------------
expectType<string>(getDisplayName({ id: 2, name: '' }));

// ---------------------------------------------------
// 3. mergeUnique 的泛型推斷應保持一致
// ---------------------------------------------------
const nums = mergeUnique([1, 2], [2, 3]);
expectType<number[]>(nums);   // ✅ 正確

const strs = mergeUnique(['a'], ['b', 'a']);
expectType<string[]>(strs);   // ✅ 正確

// ---------------------------------------------------
// 4. 確認 User 的 optional 屬性
// ---------------------------------------------------
type HasEmail = User extends { email: string } ? true : false;
expectNever<HasEmail>(true as any); // 期待編譯失敗,表示 email 不是必填

說明

  • expectType<T>(value) 只會在 value 的型別 不符合 T 時產生編譯錯誤。
  • expectNever<T>(value) 用於斷言「此程式碼路徑不可能發生」;若 value 能被推斷為 never,測試通過。
  1. package.json 加入腳本
{
  "scripts": {
    "test:type": "tsd"
  }
}

執行 npm run test:type,若任何斷言失敗,CI 會即時報錯。

3.2 在 Jest 中使用 expect‑type

  1. 安裝
npm i -D expect-type @types/jest
  1. 範例測試檔 (__tests__/utils.test.ts)
// __tests__/utils.test.ts
import { expectType } from 'expect-type';
import { getDisplayName, mergeUnique, User } from '../src/utils';

describe('utils 型別測試 (expect-type)', () => {
  test('getDisplayName 的回傳型別應為 string', () => {
    const result = getDisplayName({ id: 10, name: 'Bob' });
    expect(result).toBe('Bob');
    // 型別斷言
    expectType<string>(result);
  });

  test('mergeUnique 能正確推斷泛型', () => {
    const merged = mergeUnique([true, false], [false, true, true]);
    expect(merged).toEqual([true, false]);
    // 期望型別為 boolean[]
    expectType<boolean[]>(merged);
  });

  test('User 型別的 optional email', () => {
    const user: User = { id: 5, name: 'Carol' };
    // email 欄位不存在時仍符合 User
    expectType<User>(user);
    // 若強制寫入 undefined,仍符合
    expectType<User>({ ...user, email: undefined });
  });
});

重點expectType<T>(value)執行測試時不會產生任何 runtime 負擔,它完全由 TypeScript 編譯器在型別層面檢查。若斷言失敗,tsc 會在測試編譯階段拋出錯誤,Jest 會顯示「TypeScript compilation error」訊息。

3.3 進階範例:比較兩個型別的子集關係

// test-d/advanced.test-d.ts
import { expectAssignable, expectNotAssignable } from 'tsd';
import { User } from '../src/utils';

// 定義一個更嚴格的型別
type StrictUser = {
  id: number;
  name: string;
  email: string; // 必填
};

// User 應該可以指派給 StrictUser 嗎?答案:不行(email 為 optional)
expectNotAssignable<StrictUser>({} as User);

// 但是 StrictUser 必能指派給 User(因為多了一個必填欄位)
expectAssignable<User>({} as StrictUser);

這類測試常用於 API 版本升級 時驗證「向下相容」的規則是否仍然成立。


常見陷阱與最佳實踐

陷阱 說明 解決方式
斷言寫在錯誤檔案類型 把型別測試寫在 .test.ts(執行時測試)而非 .test-d.ts,會讓 tsd 無法正確解析。 確保 type‑only 測試檔案使用 .test-d.ts 副檔名,或在 expect-type 中使用 // @ts-ignore 只在編譯階段檢查。
泛型推斷失效 使用 anyunknown 作為參數會讓型別斷言失去意義。 在測試中盡量使用具體的字面值或 as const,避免過度寬鬆的型別。
測試過於冗長 為每個屬性都寫斷言會讓測試檔膨脹,維護成本升高。 只針對 公共 API關鍵型別(如 DTO、泛型工具)撰寫斷言;其餘可使用 expectAssignable / expectNotAssignable 進行概括性檢查。
CI 中未執行型別測試 忘記在 CI pipeline 加入 npm run test:type,導致型別錯誤未被捕捉。 package.jsontest script 中加入 && npm run test:type,或在 CI 設定檔明確呼叫。
依賴第三方型別套件版本不一致 tsd 會根據 node_modules 中的型別檔案執行,若 CI 與本機安裝版本不同,結果可能不一致。 使用 package-lock.jsonpnpm-lock.yaml 鎖定版本,確保 CI 與本地環境一致。

最佳實踐

  1. 分層測試:先用 tsd 驗證公開 .d.ts,再在 Jest/Vitest 中使用 expect-type 針對內部實作做更細緻的斷言。
  2. 保持測試與程式碼同步:若新增或修改公共型別,務必同時更新相對應的型別測試。
  3. 利用 as const:在測試資料中使用 as const 讓 TypeScript 推斷出最精確的字面型別,避免寬鬆的 string[]number[]
  4. CI 失敗即阻止部署:將型別測試設為 required,確保任何型別破壞都不會進入正式環境。

實際應用場景

1. 公開 NPM 套件的型別相容性

開發者在發佈 my-lib 時,需要保證 v1.xv2.x 的升級不會破壞舊有使用者的型別。利用 tsd 撰寫以下測試:

// test-d/v1-compat.test-d.ts
import { expectAssignable } from 'tsd';
import { PublicAPIv2 } from 'my-lib/v2';
import { PublicAPIv1 } from 'my-lib/v1';

// v2 必須能指派給 v1(向下相容)
expectAssignable<PublicAPIv1>(null as any as PublicAPIv2);

CI 若在新版本中不小心刪除或改變了必填屬性,測試即會失敗,提醒開發者調整 major 版本號。

2. 大型 React 專案的 Props 型別驗證

在 React 中,Component Props 常透過泛型與條件型別組合,導致型別錯誤不易發現。使用 expect-type

// Component.test.tsx
import { expectType } from 'expect-type';
import { Button } from '@/components/Button';

test('Button 的 onClick 只能接受 MouseEvent', () => {
  const handler = (e: React.MouseEvent<HTMLButtonElement>) => {};
  const btn = <Button onClick={handler} />;
  // 斷言 btn.props.onClick 的型別
  expectType<(e: React.MouseEvent<HTMLButtonElement>) => void>(handler);
});

這樣即使在重構 Button 時不小心把 onClick 的型別改成 any,測試仍會捕捉到。

3. 後端 TypeScript SDK 的資料模型

假設我們有一個與後端 API 溝通的 SDK,所有請求/回應皆以 TypeScript 型別描述。使用 tsd 檢查 API 回傳型別實際實作 是否一致:

// test-d/sdk-response.test-d.ts
import { expectAssignable } from 'tsd';
import { fetchUser } from '../src/sdk';
import { User } from '../src/types';

// fetchUser 必須回傳 Promise<User>
expectAssignable<Promise<User>>(fetchUser(1));

若後端調整了回傳結構,只要 SDK 沒同步更新,測試會立刻失效。


總結

型別測試是 把 TypeScript 的型別系統延伸到測試階段 的關鍵技巧。透過 tsd 我們可以在純型別檔案中斷言公開 API 的相容性;透過 expect‑type,則能在常見的單元測試框架裡同時驗證執行結果與型別正確性。

  • 型別測試讓錯誤更早被發現,尤其是第三方套件或大型代碼庫的升級衝擊。
  • 最佳實踐 包括分層測試、使用 as const、將測試納入 CI、以及鎖定依賴版本。
  • 常見陷阱 如檔案副檔名錯誤、過度寬鬆的 any、或忘記在 CI 執行,都可以透過嚴謹的流程與自動化解決。

在日常開發中,養成 每次新增或變更公共型別就同步撰寫型別測試 的習慣,將大幅提升程式碼的可維護性與團隊的開發信心。未來隨著 TypeScript 持續進化,型別測試也會成為 品質保證 的標準組件,值得每位前端/Node.js 開發者投入時間與心力。祝你在型別安全的道路上越走越遠!