TypeScript 實務開發與架構應用 ── 型別驗證(Zod / io‑ts / Yup)
簡介
在純粹的 TypeScript 開發中,型別檢查只發生在編譯階段,程式碼在執行時仍然是普通的 JavaScript。當資料來源是外部 API、使用者輸入或是資料庫回傳時,若不在執行時再次驗證,錯誤的結構會直接導致 runtime exception、資料遺失或安全漏洞。
因此,在 TypeScript 專案裡加入 Runtime 型別驗證 成為提升可靠性與維護性的關鍵。市面上常見的驗證函式庫有 Zod、io‑ts、Yup,它們各自提供不同的設計哲學與 API 風格,讓開發者可以在保持型別安全的同時,快速完成資料清洗、錯誤回報與自動型別推斷。
本篇文章會:
- 介紹三大函式庫的核心概念與使用方式。
- 透過實作範例說明如何在 API、表單與服務層級結合型別驗證。
- 探討常見陷阱、最佳實踐與適用情境,幫助你在實務專案中選對工具、寫出可讀且可維護的程式碼。
核心概念
1. 為什麼需要 Runtime 型別驗證?
- 資料來源不可信:前端表單、第三方 API、WebSocket 都可能回傳不符合預期的結構。
- 跨語言/跨服務溝通:即使後端使用 TypeScript,序列化/反序列化過程仍會失去型別資訊。
- 防止程式碼退化:在大型專案中,隨著時間演進,型別定義可能被遺忘或改動,驗證層可作為最後的防線。
重點:型別驗證不會取代 TypeScript 的編譯時檢查,而是 補足 兩者的盲點,讓系統在 開發階段、測試階段、上線後 都能保持一致的資料品質。
2. 三大函式庫的設計哲學
| 函式庫 | 主要特點 | 型別推斷方式 | 依賴 |
|---|---|---|---|
| Zod | 宣告式、無副作用、純函式 | 直接從 schema 推斷 TypeScript 型別 (z.infer<>) |
無外部依賴 |
| io‑ts | 基於函子 (Functor) 與 Monad 的 FP 風格 | 使用 TypeOf<> 取得型別 |
fp-ts(可選) |
| Yup | 類似表單驗證的鏈式 API、支援自訂驗證訊息 | 需手動宣告或使用 yup.InferType<> |
lodash(在舊版) |
- Zod:適合追求簡潔、快速上手的團隊;錯誤訊息可自訂且結構化。
- io‑ts:如果專案已經大量使用
fp-ts,選擇 io‑ts 能保持函式式編程的一致性。 - Yup:在與 UI 表單庫(如 Formik、React Hook Form)整合時,Yup 的鏈式 API 更直觀。
3. 基本使用方式
3.1 Zod
import { z } from "zod";
// 1. 定義 schema
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
// 2. 取得對應的 TypeScript 型別
type User = z.infer<typeof UserSchema>;
// 3. 驗證資料
function parseUser(payload: unknown): User {
// 若驗證失敗會拋出 ZodError
return UserSchema.parse(payload);
}
// 範例呼叫
const raw = JSON.parse(`{"id":"c9b1e9c0-1b2d-4f6a-9c77-5e6d3b9c8c9f","name":"Alice","email":"alice@example.com"}`);
const user = parseUser(raw); // ✅ 型別安全的 User 物件
技巧:
safeParse會回傳{ success: boolean, data?: T, error?: ZodError },適合在非例外流程(如 API 回傳錯誤)時使用。
3.2 io‑ts
import * as t from "io-ts";
import { isRight } from "fp-ts/Either";
// 1. 定義 codec
const ProductCodec = t.type({
sku: t.string,
price: t.number,
tags: t.array(t.string),
// optional 欄位使用 partial
description: t.union([t.string, t.undefined]),
});
// 2. 推導 TypeScript 型別
type Product = t.TypeOf<typeof ProductCodec>;
// 3. 驗證資料
function decodeProduct(payload: unknown): Product {
const result = ProductCodec.decode(payload);
if (isRight(result)) {
return result.right; // ✅ 已驗證的 Product
}
// 取得錯誤訊息
const errors = result.left.map(e => e.context.map(({ key }) => key).join('.')).join(', ');
throw new Error(`Product decode failed: ${errors}`);
}
// 範例使用
const json = `{"sku":"A123","price":199.99,"tags":["electronics","sale"]}`;
const product = decodeProduct(JSON.parse(json));
要點:io‑ts 的錯誤資訊是
ValidationError陣列,使用fp-ts的Either讓錯誤處理更具函式式風格。
3.3 Yup
import * as yup from "yup";
// 1. 建立 schema
const OrderSchema = yup.object({
orderId: yup.string().required(),
amount: yup.number().positive().required(),
items: yup.array(
yup.object({
productId: yup.string().required(),
qty: yup.number().integer().min(1).required(),
})
).min(1),
});
// 2. 型別推斷(Yup 4.x 以上支援)
type Order = yup.InferType<typeof OrderSchema>;
// 3. 驗證(非拋例外版)
async function validateOrder(payload: unknown): Promise<Order> {
return await OrderSchema.validate(payload, { abortEarly: false });
}
// 範例
(async () => {
const raw = { orderId: "ORD-001", amount: 350, items: [{ productId: "P01", qty: 2 }] };
try {
const order = await validateOrder(raw);
console.log("✅", order);
} catch (err) {
if (err instanceof yup.ValidationError) {
console.error("❌ Validation errors:", err.errors);
}
}
})();
提醒:Yup 的
validate會回傳 Promise,適合在 async/await 流程中使用;若想同步驗證,可改用validateSync。
4. 進階範例:結合 Express、React Hook Form 與 Zod
以下示範一個完整的 前後端 流程,使用 Zod 作為唯一的資料驗證來源,確保前端與後端的型別一致。
4.1 後端(Express)
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
// 共享的 Zod schema
export const RegisterSchema = z.object({
username: z.string().min(3).max(20),
password: z.string().min(8),
email: z.string().email(),
});
type RegisterDTO = z.infer<typeof RegisterSchema>;
app.post("/api/register", (req, res) => {
const result = RegisterSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.format() });
}
// 此時 result.data 已符合 RegisterDTO
const user: RegisterDTO = result.data;
// ... 實作註冊邏輯
res.status(201).json({ message: "註冊成功", user });
});
app.listen(3000, () => console.log("🚀 Server listening on :3000"));
4.2 前端(React + React Hook Form)
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
// 直接匯入後端定義的 schema(可透過 monorepo 或 npm package 共享)
import { RegisterSchema } from "../server";
type RegisterForm = z.infer<typeof RegisterSchema>;
export default function Register() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(RegisterSchema),
});
const onSubmit = async (data: RegisterForm) => {
try {
const res = await axios.post("/api/register", data);
alert(res.data.message);
} catch (e: any) {
if (e.response?.data?.errors) {
console.error("Server validation errors:", e.response.data.errors);
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("username")} placeholder="使用者名稱" />
{errors.username && <p>{errors.username.message}</p>}
<input type="password" {...register("password")} placeholder="密碼" />
{errors.password && <p>{errors.password.message}</p>}
<input {...register("email")} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">註冊</button>
</form>
);
}
關鍵:透過 同一份 Zod schema,前後端的驗證規則保持一致,減少「前端驗證通過、後端卻失敗」的情況。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 忘記同步 schema | 前後端各自維護不同的驗證規則,導致不一致。 | 使用 monorepo 或 npm package 共享 schema,或採用 codegen(如 zod-to-ts)產生型別。 |
過度依賴 any |
在 safeParse 前直接把 unknown 斷言為 any,失去驗證意義。 |
永遠 以 unknown 為入口,僅在驗證成功後才斷言為具體型別。 |
| 錯誤訊息不友善 | ZodError、io‑ts 的錯誤結構較為技術性,直接回傳給使用者不佳。 | 透過 error.format()(Zod)或自訂 errorMap,將錯誤轉換成 UI 可讀的文字。 |
| 驗證成本過高 | 大型陣列或深層物件在每次請求都重新驗證,可能影響效能。 | 分層驗證:只驗證入口結構,內部資料可延後或使用 lazy schema(Zod)/recursive(io‑ts)。 |
| 忘記處理異步驗證 | Yup 支援 test 內部使用 async,但未 await 會導致驗證提前通過。 |
確保所有自訂驗證均為 async 且在 await 中調用。 |
最佳實踐
單一來源的真相 (Single Source of Truth)
- 把 schema 放在共享模組或
libs/validation目錄,前後端皆從此處導入。
- 把 schema 放在共享模組或
先驗證、後型別斷言
const result = MySchema.safeParse(input); if (!result.success) throw new ValidationError(result.error); const data = result.data; // 已安全型別結合型別推斷與自動補全
- 使用
z.infer<>、t.TypeOf<>、yup.InferType<>,IDE 會自動補全,減少手寫介面的錯誤。
- 使用
錯誤訊息國際化 (i18n)
- Zod 允許自訂
errorMap,Yup 也支援setLocale,可將訊息抽離到語言檔。
- Zod 允許自訂
測試驗證邏輯
- 為每個 schema 撰寫單元測試(合法/非法案例),確保未來修改不會破壞既有規則。
實際應用場景
1. API Gateway / Edge Function
在微服務架構下,API Gateway 會先對外部請求做資料驗證,僅把符合規範的 payload 轉發至內部服務。使用 Zod 的 parseAsync 或 Yup 的 validate,可在 Edge Function(如 Cloudflare Workers)中即時拋出 400 錯誤,減少服務端負載。
2. 表單驗證與 UI 錯誤呈現
React Hook Form + Zod、或 Formik + Yup,讓表單的 client‑side 與 server‑side 驗證共用同一套規則。錯誤訊息可直接映射到 UI 元件,提升使用者體驗。
3. 設定檔與環境變數的安全載入
import * as z from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.string().regex(/^\d+$/).transform(Number),
DATABASE_URL: z.string().url(),
});
const env = EnvSchema.parse(process.env); // 若缺少變數會直接拋錯
export default env;
這樣的寫法保證 部署時 若環境變數缺失或格式錯誤,應用會在啟動階段即失敗,避免在執行時才發現問題。
4. 事件驅動系統(Kafka / RabbitMQ)
在消費訊息時,使用 io‑ts 的 decode 搭配 Either,可以把 無效訊息 直接送至 dead‑letter queue,保持主流程的乾淨。
consumer.on("message", async (msg) => {
const result = EventCodec.decode(JSON.parse(msg.value.toString()));
if (isRight(result)) {
await handleEvent(result.right);
} else {
await deadLetter(msg, result.left);
}
});
5. 靜態程式碼分析與自動生成
利用 zod-to-json-schema、io-ts-codegen 或 yup-to-json-schema,把 TypeScript 的 runtime schema 轉成 JSON Schema,再供 OpenAPI、Swagger 或前端自動表單產生器使用,形成 前後端一致的契約。
總結
- Runtime 型別驗證 是在 TypeScript 生態中彌補編譯時檢查不足的重要防線。
- Zod、io‑ts、Yup 各有優勢:Zod 速度快、API 簡潔;io‑ts 符合函式式編程;Yup 與表單庫結合自然。
- 共享 schema、先驗證後斷言、錯誤訊息本地化 是實務上提升可維護性的關鍵做法。
- 在 API、表單、設定檔、事件系統 等多種場景中,都能透過上述函式庫建立一致且安全的資料流。
把驗證邏輯寫成 可重用、可測試、可共享 的模組,將讓專案在面對日益複雜的資料交互時,仍能保持 型別安全 與 開發效率。祝你在 TypeScript 的世界裡,寫出既優雅又可靠的程式碼! 🚀