TypeScript – 測試與型別驗證:型別測試(tsd / expect‑type)
簡介
在大型前端或 Node.js 專案中,型別安全往往是最能提升維護性與開發效率的保證。即使 TypeScript 已經在編譯階段幫我們捕捉大多數錯誤,仍有兩類情況會讓型別資訊「走失」:
- 第三方函式庫的型別宣告不完整或錯誤。
- 在函式實作與呼叫端之間,型別推斷因條件分支或泛型過度抽象而失真。
這時候,僅靠 tsc 的編譯結果已不足以驗證「程式碼的型別行為」是否符合預期。型別測試(type‑testing)提供了一種「在測試階段」斷言型別的手段,讓我們能在 CI 中自動檢查型別契約是否被破壞。本文將介紹兩個主流工具 tsd 與 expect‑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‑only 的 expect 風格。 |
與單元測試混合、在同一測試檔案中同時驗證執行結果與型別 |
程式碼範例
以下示範如何在同一個專案中同時使用 tsd 與 expect‑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 撰寫型別測試
- 安裝
npm i -D tsd
- 在
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,測試通過。
- 在
package.json加入腳本
{
"scripts": {
"test:type": "tsd"
}
}
執行 npm run test:type,若任何斷言失敗,CI 會即時報錯。
3.2 在 Jest 中使用 expect‑type
- 安裝
npm i -D expect-type @types/jest
- 範例測試檔 (
__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 只在編譯階段檢查。 |
| 泛型推斷失效 | 使用 any 或 unknown 作為參數會讓型別斷言失去意義。 |
在測試中盡量使用具體的字面值或 as const,避免過度寬鬆的型別。 |
| 測試過於冗長 | 為每個屬性都寫斷言會讓測試檔膨脹,維護成本升高。 | 只針對 公共 API、關鍵型別(如 DTO、泛型工具)撰寫斷言;其餘可使用 expectAssignable / expectNotAssignable 進行概括性檢查。 |
| CI 中未執行型別測試 | 忘記在 CI pipeline 加入 npm run test:type,導致型別錯誤未被捕捉。 |
在 package.json 的 test script 中加入 && npm run test:type,或在 CI 設定檔明確呼叫。 |
| 依賴第三方型別套件版本不一致 | tsd 會根據 node_modules 中的型別檔案執行,若 CI 與本機安裝版本不同,結果可能不一致。 |
使用 package-lock.json 或 pnpm-lock.yaml 鎖定版本,確保 CI 與本地環境一致。 |
最佳實踐
- 分層測試:先用
tsd驗證公開.d.ts,再在 Jest/Vitest 中使用expect-type針對內部實作做更細緻的斷言。 - 保持測試與程式碼同步:若新增或修改公共型別,務必同時更新相對應的型別測試。
- 利用
as const:在測試資料中使用as const讓 TypeScript 推斷出最精確的字面型別,避免寬鬆的string[]、number[]。 - CI 失敗即阻止部署:將型別測試設為
required,確保任何型別破壞都不會進入正式環境。
實際應用場景
1. 公開 NPM 套件的型別相容性
開發者在發佈 my-lib 時,需要保證 v1.x 到 v2.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 開發者投入時間與心力。祝你在型別安全的道路上越走越遠!