每次你的 API 收到請求,你就會驗證它。每次函式收到來自外部系統的參數,你就會檢查它。如果用手動方式處理,單一 endpoint 累積的驗證程式碼可能比業務邏輯還多。
這就是 runtime contracts 的隱藏代價。你需要它們,因為 type system 會說謊:透過 HTTP 傳來的 JSON、資料庫欄位,以及環境變數,在 runtime 都是以 untyped data 的形式抵達。但要是用徒手撰寫的 if 陳述式來實作,簡單的函式就會變成難以閱讀的防禦性程式碼高牆。
有更好的做法。
runtime contracts 究竟是什麼
runtime contract 是函式對其輸入與輸出所做出的保證。如果你傳入有效的資料,就會得到有效的資料。如果你違反了 contract,函式就會快速失敗並給出明確的錯誤。
這就是 Design by Contract,從 Eiffel 開始就已經存在。這個概念並不新穎。新穎的是工具——讓你可以不用為每個欄位都寫 validator 就能表達 contracts。
核心洞見是:contracts 是 schemas,而不是程式碼。schema 描述形狀、約束與關係。用手動方式檢查這些東西的程式碼,只不過是用錯誤語言寫成的 schema。
樣板程式碼的陷阱
以下是手動驗證在實務中的樣子。你有一個處理使用者註冊請求的函式:
function createUser(payload: unknown) {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid payload");
}
if (!("email" in payload) || typeof (payload as any).email !== "string") {
throw new Error("email is required and must be a string");
}
if (!(payload as any).email.includes("@")) {
throw new Error("email must be valid");
}
if (!("age" in payload) || typeof (payload as any).age !== "number") {
throw new Error("age is required and must be a number");
}
if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
throw new Error("age must be an integer of at least 13");
}
if (
"tags" in payload &&
(!Array.isArray((payload as any).tags) ||
!(payload as any).tags.every((t: any) => typeof t === "string"))
) {
throw new Error("tags must be an array of strings");
}
// ... finally, the actual work
}
這令人疲憊、容易出錯,而且不完整。你有檢查 email 是否符合真正的電子郵件格式嗎?age 是否不是 NaN?tags 的元素是否為非空字串?大概沒有,因為你已經累了。
而這只是一個函式。乘上系統中每一個邊界,你就會知道問題有多大。
宣告式 schemas 如何取代樣板程式碼
與其撰寫 validators,不如描述形狀,讓函式庫來 enforce。
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13),
tags: z.array(z.string().min(1)).optional(),
});
function createUser(payload: unknown) {
const user = UserSchema.parse(payload);
// user is typed as { email: string; age: number; tags?: string[] }
// actual business logic goes here
}
schema 就是文件。它也是 validator、type definition,以及錯誤訊息產生器。在一個地方修改 schema,contract 就會在每一處使用的地方同步更新。
這不是魔法。函式庫會在 runtime 遍歷 schema 並套用檢查。差別在於,這些檢查是由小型、可重複使用的 primitives 組合而成,而不是每次都要徒手撰寫。
function boundaries 上的 contracts
真正的威力來自於直接將 schemas 附加到函式的輸入與輸出。你希望 contract 存在於邊界上,而不是滲透進實作細節中。
function withContract<TInput, TOutput>(
inputSchema: z.ZodSchema<TInput>,
outputSchema: z.ZodSchema<TOutput>,
handler: (input: TInput) => TOutput
) {
return (rawInput: unknown): TOutput => {
const input = inputSchema.parse(rawInput);
const output = handler(input);
return outputSchema.parse(output);
};
}
const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
const createUser = withContract(
UserSchema,
UserResponseSchema,
(user) => {
// zero validation code here
return db.users.create(user);
}
);
現在函式主體裡完全沒有驗證程式碼。contract 會在邊緣被 enforce,而編譯器仍然知道內部的型別,因為 schema 會推論它們。
哪些地方會出問題:效能、推論與過度驗證
宣告式 schemas 不是免費的。在採用之前,你應該了解其中的權衡。
效能是最明顯的顧慮。解析複雜的 schema 比幾個 if 檢查還慢。實務上,對大多數 API 來說,這個差異微不足道。對於簡單物件,Zod 大約比徒手撰寫的檢查慢 10 倍。對大多數 HTTP handler 來說,這只是微秒級的差異。如果你要在 hot loop 中驗證數百萬筆記錄,請先進行 benchmark。
type inference 的差距也可能讓你吃苦頭。從 schemas 推論出來的型別,有時候比你手寫的還嚴格或還寬鬆。你可能需要定義自訂型別,然後讓 schema 去配合它們,而不是反過來。
過度驗證是隱形的殺手。不是每個函式都需要 runtime contract。內部函式如果接收的資料已經在 API 邊界驗證過,就不需要重新檢查。將 contracts 套用在系統邊界上,而不是每一個輔助函式。
如何在不 rewrite codebase 的情況下實作
你不需要大爆炸式的遷移。從你的 API entry points 開始。
- 挑選一個對外暴露的函式或 route handler。
- 為它的輸入撰寫一個 schema。
- 用 schema parsing 取代手動驗證。
- 讓 schema 推論 TypeScript type。刪除你手寫的 interface。
先對一個 endpoint 這樣做,然後是一個 service,再來是一個 domain。一週之內,你就會發現測試套件縮水了,因為你不再需要測試那些 schema 已經涵蓋的驗證邊界情況。
如果你沒有使用 TypeScript,這個模式同樣適用。Python 有 Pydantic。Go 有 go-playground/validator。Rust 有 validator。原則都一樣:描述 contract,產生檢查。
常見問題
runtime contracts 會取代 unit tests 嗎?
不會。它們取代的是 unit tests 中驗證輸入檢查的那個子集。你仍然需要測試業務邏輯、錯誤處理與整合行為。
自訂驗證規則呢?
每個認真的 schema 函式庫都支援自訂 refinement。請謹慎使用。如果你的自訂規則超過五行,它大概應該放在函式主體裡,而不是 schema 中。
輸出時也應該驗證嗎?
是的,對於關鍵函式來說應該如此。輸出 contract 可以捕捉到業務邏輯產生畸形資料的 bug。在源頭就失敗,比讓壞資料傳播到另一個 service 來得便宜。
結論
對於接觸 untyped data 的系統來說,runtime contracts 是不可妥協的。唯一的選擇是,你要用樣板程式碼還是 schema 函式庫來支付這筆代價。
schema 函式庫只需要一個 dependency 和少許效能開銷。樣板程式碼則會耗損你的可維護性、正確性,以及理智。
選擇函式庫吧。