本文 AI 產出,尚未審核

TypeScript 實務開發與架構應用 ── 型別驗證(Zod / io‑ts / Yup)


簡介

在純粹的 TypeScript 開發中,型別檢查只發生在編譯階段,程式碼在執行時仍然是普通的 JavaScript。當資料來源是外部 API、使用者輸入或是資料庫回傳時,若不在執行時再次驗證,錯誤的結構會直接導致 runtime exception、資料遺失或安全漏洞。

因此,在 TypeScript 專案裡加入 Runtime 型別驗證 成為提升可靠性與維護性的關鍵。市面上常見的驗證函式庫有 Zod、io‑ts、Yup,它們各自提供不同的設計哲學與 API 風格,讓開發者可以在保持型別安全的同時,快速完成資料清洗、錯誤回報與自動型別推斷。

本篇文章會:

  1. 介紹三大函式庫的核心概念與使用方式。
  2. 透過實作範例說明如何在 API、表單與服務層級結合型別驗證。
  3. 探討常見陷阱、最佳實踐與適用情境,幫助你在實務專案中選對工具、寫出可讀且可維護的程式碼。

核心概念

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-tsEither 讓錯誤處理更具函式式風格。


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 前後端各自維護不同的驗證規則,導致不一致。 使用 monoreponpm 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 中調用。

最佳實踐

  1. 單一來源的真相 (Single Source of Truth)

    • 把 schema 放在共享模組或 libs/validation 目錄,前後端皆從此處導入。
  2. 先驗證、後型別斷言

    const result = MySchema.safeParse(input);
    if (!result.success) throw new ValidationError(result.error);
    const data = result.data; // 已安全型別
    
  3. 結合型別推斷與自動補全

    • 使用 z.infer<>t.TypeOf<>yup.InferType<>,IDE 會自動補全,減少手寫介面的錯誤。
  4. 錯誤訊息國際化 (i18n)

    • Zod 允許自訂 errorMap,Yup 也支援 setLocale,可將訊息抽離到語言檔。
  5. 測試驗證邏輯

    • 為每個 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‑sideserver‑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-schemaio-ts-codegenyup-to-json-schema,把 TypeScript 的 runtime schema 轉成 JSON Schema,再供 OpenAPI、Swagger 或前端自動表單產生器使用,形成 前後端一致的契約


總結

  • Runtime 型別驗證 是在 TypeScript 生態中彌補編譯時檢查不足的重要防線。
  • Zod、io‑ts、Yup 各有優勢:Zod 速度快、API 簡潔;io‑ts 符合函式式編程;Yup 與表單庫結合自然。
  • 共享 schema、先驗證後斷言、錯誤訊息本地化 是實務上提升可維護性的關鍵做法。
  • API、表單、設定檔、事件系統 等多種場景中,都能透過上述函式庫建立一致且安全的資料流。

把驗證邏輯寫成 可重用、可測試、可共享 的模組,將讓專案在面對日益複雜的資料交互時,仍能保持 型別安全開發效率。祝你在 TypeScript 的世界裡,寫出既優雅又可靠的程式碼! 🚀