TypeScript執行時型別校驗實戰:從Zod到TypeBox的6種生產模式

前端工程

TypeScript的型別在執行時就是空氣

你精心定義了interface User,編譯零錯誤,線上卻炸了——API回傳了null代替string,第三方SDK的型別宣告和實際行為完全不一致,JSON.parse()的回傳值永遠是anyTypeScript的型別在編譯後就被完全抹除,執行時沒有任何保障。2026年,執行時型別校驗已經從「可選」變成了「必選」——Zod、TypeBox、io-ts、valibot四大方案各有所長,tRPC更是將端到端型別安全推向了新高度。

本文將從核心概念出發,帶你完成Zod Schema定義→TypeBox JSON Schema生成→io-ts Either錯誤處理→valibot輕量校驗→tRPC端到端型別安全→生產API校驗中介軟體的6種生產模式,從選型到落地,一步不落。


核心概念

概念 說明
Runtime Type Checking 執行時對資料結構進行校驗,彌補TypeScript型別抹除的缺陷
Schema Validation 宣告式定義資料結構,同時提供校驗邏輯和型別推導
Type Inference 從Schema自動推導TypeScript型別,消除interface與校驗邏輯的重複維護
JSON Schema 標準化的資料描述格式,可被多種工具和語言消費
Branded Types 透過唯一標記區分同構型別,防止型別混用(如UserId vs OrderId)
Decoding 校驗+轉換的組合操作,將原始輸入解碼為型別安全的輸出

校驗流程

資料流入 → Schema.parse(data) → 校驗通過 → 回傳型別安全的資料
                        ↓ 校驗失敗
                   拋出錯誤 / 回傳Either結果

型別推導流程:
1. 定義Schema: const UserSchema = z.object({...})
2. 推導型別: type User = z.infer<typeof UserSchema>
3. 執行時校驗: UserSchema.parse(apiResponse)
4. 編譯期型別: parse回傳值自動獲得User型別

JSON Schema流程:
1. 定義Schema: const UserSchema = Type.Object({...})
2. 生成JSON Schema: Value.Check(UserSchema, data)
3. 型別推導: type User = Static<typeof UserSchema>
4. 跨語言消費: JSON Schema可被任何語言驗證

問題分析:執行時型別校驗的5大挑戰

  1. 型別抹除陷阱:TypeScript編譯後型別資訊完全消失,JSON.parse()、API回應、第三方函式庫回傳值無法保證型別安全
  2. Schema與型別割裂:手動維護interface和校驗邏輯兩份程式碼,極易不一致,改了interface忘了改校驗
  3. 錯誤處理不統一:不同校驗函式庫的錯誤格式各異,ZodError、io-ts的Either、ajv的ValidationError,難以統一處理
  4. 效能與套件體積權衡:Zod功能強大但體積較大,io-ts學習曲線陡峭,valibot輕量但生態不成熟
  5. 跨語言互操作:微服務架構下前後端、不同服務間需要共享資料契約,JSON Schema成為唯一通用語言

分步實操:6種生產模式

模式1:Zod Schema定義與型別推導

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  avatar: z.string().url().optional(),
  createdAt: z.date(),
  tags: z.array(z.string()).default([]),
  metadata: z.record(z.string(), z.unknown()).default({}),
});

type User = z.infer<typeof UserSchema>;

const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
type CreateUserInput = z.infer<typeof CreateUserSchema>;

const UpdateUserSchema = UserSchema.partial().omit({ id: true, createdAt: true });
type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

const rawInput = {
  name: '張三',
  email: 'zhang@example.com',
  age: 28,
  role: 'admin' as const,
  tags: ['typescript'],
  metadata: {},
};

const result = CreateUserSchema.safeParse(rawInput);

if (result.success) {
  const user: CreateUserInput = result.data;
  console.log('校驗通過:', user.name, user.email);
} else {
  console.error('校驗失敗:', result.error.flatten());
}

const BrandedUserId = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof BrandedUserId>;

function getUser(id: UserId): User | null {
  return null;
}

const rawId = '550e8400-e29b-41d4-a716-446655440000';
const userId = BrandedUserId.parse(rawId);
getUser(userId);

const PaginationSchema = z.object({
  page: z.number().int().positive().default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  sortBy: z.string().optional(),
  sortOrder: z.enum(['asc', 'desc']).default('asc'),
});

type Pagination = z.infer<typeof PaginationSchema>;

const SearchParamsSchema = PaginationSchema.extend({
  keyword: z.string().min(1),
  filters: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
});

type SearchParams = z.infer<typeof SearchParamsSchema>;

const ApiResponseSchema = z.object({
  code: z.number(),
  message: z.string(),
  data: z.unknown(),
  timestamp: z.number(),
});

function validateApiResponse<T>(schema: z.ZodSchema<T>, response: unknown): T {
  const baseResult = ApiResponseSchema.parse(response);
  return schema.parse(baseResult.data);
}

const UserListResponseSchema = z.array(UserSchema);
const users = validateApiResponse(UserListResponseSchema, {
  code: 0,
  message: 'ok',
  data: [],
  timestamp: Date.now(),
});

模式2:TypeBox JSON Schema生成

import { Type, Static, TSchema } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';

const UserSchema = Type.Object({
  id: Type.String({ format: 'uuid' }),
  name: Type.String({ minLength: 2, maxLength: 50 }),
  email: Type.String({ format: 'email' }),
  age: Type.Integer({ minimum: 0, maximum: 150 }),
  role: Type.Union([Type.Literal('admin'), Type.Literal('editor'), Type.Literal('viewer')]),
  avatar: Type.Optional(Type.String({ format: 'uri' })),
  createdAt: Type.String({ format: 'date-time' }),
  tags: Type.Array(Type.String()),
  metadata: Type.Record(Type.String(), Type.Unknown()),
});

type User = Static<typeof UserSchema>;

const CreateUserSchema = Type.Omit(UserSchema, ['id', 'createdAt']);
type CreateUserInput = Static<typeof CreateUserSchema>;

const UpdateUserSchema = Type.Partial(Type.Omit(UserSchema, ['id', 'createdAt']));
type UpdateUserInput = Static<typeof UpdateUserSchema>;

const rawInput: unknown = {
  id: '550e8400-e29b-41d4-a716-446655440000',
  name: '張三',
  email: 'zhang@example.com',
  age: 28,
  role: 'admin',
  createdAt: new Date().toISOString(),
  tags: ['typescript'],
  metadata: {},
};

if (Value.Check(UserSchema, rawInput)) {
  const user: User = Value.Cast(UserSchema, rawInput);
  console.log('校驗通過:', user.name);
} else {
  const errors = [...Value.Errors(UserSchema, rawInput)];
  console.error('校驗失敗:', errors);
}

const PaginationSchema = Type.Object({
  page: Type.Number({ minimum: 1, default: 1 }),
  pageSize: Type.Number({ minimum: 1, maximum: 100, default: 20 }),
  sortBy: Type.Optional(Type.String()),
  sortOrder: Type.Union([Type.Literal('asc'), Type.Literal('desc')]),
});

type Pagination = Static<typeof PaginationSchema>;

const jsonSchemaString = JSON.stringify(UserSchema, null, 2);
console.log('JSON Schema輸出:', jsonSchemaString);

const ErrorResponseSchema = Type.Object({
  code: Type.Number(),
  message: Type.String(),
  details: Type.Optional(Type.Array(Type.Object({
    field: Type.String(),
    message: Type.String(),
  }))),
});

type ErrorResponse = Static<typeof ErrorResponseSchema>;

function createApiRoute<T extends TSchema>(
  method: 'GET' | 'POST' | 'PUT' | 'DELETE',
  path: string,
  bodySchema: T,
  responseSchema: TSchema,
) {
  return {
    method,
    path,
    schema: {
      body: bodySchema,
      response: {
        200: responseSchema,
        400: ErrorResponseSchema,
      },
    },
  };
}

const createUserRoute = createApiRoute(
  'POST',
  '/api/users',
  CreateUserSchema,
  UserSchema,
);

const defaults = Value.Create(PaginationSchema);
console.log('預設值:', defaults);

模式3:io-ts Either模式錯誤處理

import * as t from 'io-ts';
import { either, isRight, isLeft, fold } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

const UserCodec = t.type({
  id: t.string,
  name: t.string,
  email: t.string,
  age: t.number,
  role: t.union([t.literal('admin'), t.literal('editor'), t.literal('viewer')]),
  avatar: t.union([t.string, t.undefined]),
  tags: t.array(t.string),
  metadata: t.record(t.string, t.unknown),
});

type User = t.TypeOf<typeof UserCodec>;

const CreateUserCodec = t.type({
  name: t.string,
  email: t.string,
  age: t.number,
  role: t.union([t.literal('admin'), t.literal('editor'), t.literal('viewer')]),
  tags: t.array(t.string),
  metadata: t.record(t.string(), t.unknown()),
});

type CreateUserInput = t.TypeOf<typeof CreateUserCodec>;

const rawInput: unknown = {
  name: '張三',
  email: 'zhang@example.com',
  age: 28,
  role: 'admin',
  tags: ['typescript'],
  metadata: {},
};

const decodeResult = CreateUserCodec.decode(rawInput);

pipe(
  decodeResult,
  fold(
    (errors) => {
      const formatted = errors.map((err) => ({
        path: err.context.map((c) => c.key).filter(Boolean).join('.'),
        expected: err.context[err.context.length - 1]?.type.name,
        received: typeof err.value,
      }));
      console.error('解碼失敗:', formatted);
    },
    (data) => {
      console.log('解碼成功:', data.name);
    },
  ),
);

const EmailCodec = new t.Type<string, string, unknown>(
  'Email',
  t.string.is,
  (u, c) =>
    either.chain(t.string.validate(u, c), (s) => {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      return emailRegex.test(s) ? t.success(s) : t.failure(u, c, '信箱格式不正確');
    }),
  t.string.encode,
);

const PositiveIntCodec = new t.Type<number, number, unknown>(
  'PositiveInt',
  t.number.is,
  (u, c) =>
    either.chain(t.number.validate(u, c), (n) => {
      return Number.isInteger(n) && n > 0 ? t.success(n) : t.failure(u, c, '必須是正整數');
    }),
  t.number.encode,
);

const StrictUserCodec = t.type({
  id: t.string,
  name: t.string,
  email: EmailCodec,
  age: PositiveIntCodec,
  role: t.union([t.literal('admin'), t.literal('editor'), t.literal('viewer')]),
  tags: t.array(t.string),
});

type StrictUser = t.TypeOf<typeof StrictUserCodec>;

const PaginatedResponseCodec = <C extends t.Mixed>(codec: C) =>
  t.type({
    items: t.array(codec),
    total: t.number,
    page: t.number,
    pageSize: t.number,
  });

const UserListResponseCodec = PaginatedResponseCodec(UserCodec);
type UserListResponse = t.TypeOf<typeof UserListResponseCodec>;

const apiResponse: unknown = {
  items: [],
  total: 0,
  page: 1,
  pageSize: 20,
};

const listResult = UserListResponseCodec.decode(apiResponse);
if (isRight(listResult)) {
  console.log('列表解碼成功,總數:', listResult.right.total);
}

function validateOrThrow<C extends t.Mixed>(codec: C, data: unknown): t.TypeOf<C> {
  const result = codec.decode(data);
  if (isLeft(result)) {
    const messages = result.left.map(
      (e) => `${e.context.map((c) => c.key).filter(Boolean).join('.')}: expected ${e.context[e.context.length - 1]?.type.name}`,
    );
    throw new Error(`Validation failed: ${messages.join('; ')}`);
  }
  return result.right;
}

const user = validateOrThrow(UserCodec, rawInput);

模式4:valibot輕量校驗

import * as v from 'valibot';

const UserSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
  name: v.pipe(v.string(), v.minLength(2), v.maxLength(50)),
  email: v.pipe(v.string(), v.email()),
  age: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(150)),
  role: v.picklist(['admin', 'editor', 'viewer']),
  avatar: v.optional(v.pipe(v.string(), v.url())),
  createdAt: v.date(),
  tags: v.optional(v.array(v.string()), []),
  metadata: v.optional(v.record(v.string(), v.unknown()), {}),
});

type User = v.InferOutput<typeof UserSchema>;

const CreateUserSchema = v.omit(UserSchema, ['id', 'createdAt']);
type CreateUserInput = v.InferOutput<typeof CreateUserSchema>;

const UpdateUserSchema = v.partial(v.omit(UserSchema, ['id', 'createdAt']));
type UpdateUserInput = v.InferOutput<typeof UpdateUserSchema>;

const rawInput = {
  name: '張三',
  email: 'zhang@example.com',
  age: 28,
  role: 'admin' as const,
  tags: ['typescript'],
  metadata: {},
};

const result = v.safeParse(CreateUserSchema, rawInput);

if (result.success) {
  const user: CreateUserInput = result.output;
  console.log('校驗通過:', user.name);
} else {
  console.error('校驗失敗:', result.issues.map((issue) => ({
    path: issue.path?.map((p) => p.key).join('.'),
    message: issue.message,
  })));
}

const EmailSchema = v.pipe(
  v.string(),
  v.email('信箱格式不正確'),
  v.toLowerCase(),
);

const PositiveIntSchema = v.pipe(
  v.number(),
  v.integer('必須是整數'),
  v.minValue(1, '必須是正整數'),
);

const PaginationSchema = v.object({
  page: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
  pageSize: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)), 20),
  sortBy: v.optional(v.string()),
  sortOrder: v.optional(v.picklist(['asc', 'desc']), 'asc'),
});

type Pagination = v.InferOutput<typeof PaginationSchema>;

const SearchParamsSchema = v.intersect([
  PaginationSchema,
  v.object({
    keyword: v.pipe(v.string(), v.minLength(1)),
    filters: v.optional(v.record(v.string(), v.union([v.string(), v.number(), v.boolean()])), {}),
  }),
]);

type SearchParams = v.InferOutput<typeof SearchParamsSchema>;

const DateRangeSchema = v.object({
  start: v.pipe(v.string(), v.isoTimestamp()),
  end: v.pipe(v.string(), v.isoTimestamp()),
});

const DateRangeWithCheck = v.pipe(
  DateRangeSchema,
  v.check(
    (input) => new Date(input.start) < new Date(input.end),
    '開始日期必須早於結束日期',
  ),
);

const rawDateRange = { start: '2026-01-01T00:00:00Z', end: '2026-12-31T23:59:59Z' };
const dateResult = v.safeParse(DateRangeWithCheck, rawDateRange);
if (dateResult.success) {
  console.log('日期範圍校驗通過');
}

const TransformSchema = v.pipe(
  v.string(),
  v.trim(),
  v.transform((s) => parseInt(s, 10)),
  v.number(),
  v.minValue(0),
  v.maxValue(100),
);

const scoreResult = v.parse(TransformSchema, ' 85 ');
console.log('轉換結果:', scoreResult, typeof scoreResult);

模式5:tRPC端到端型別安全

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<Context>().create();

const router = t.router;
const publicProcedure = t.procedure;

interface Context {
  userId: string | null;
  requestId: string;
}

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  avatar: z.string().url().optional(),
  createdAt: z.date(),
  tags: z.array(z.string()).default([]),
});

type User = z.infer<typeof UserSchema>;

const CreateUserInput = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  tags: z.array(z.string()).optional().default([]),
});

const UpdateUserInput = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50).optional(),
  email: z.string().email().optional(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['admin', 'editor', 'viewer']).optional(),
});

const PaginationInput = z.object({
  page: z.number().int().positive().default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  sortBy: z.enum(['name', 'email', 'age', 'createdAt']).optional(),
  sortOrder: z.enum(['asc', 'desc']).default('asc'),
});

const userRouter = router({
  list: publicProcedure
    .input(PaginationInput)
    .query(async ({ input }) => {
      const { page, pageSize, sortBy, sortOrder } = input;
      return {
        items: [] as User[],
        total: 0,
        page,
        pageSize,
      };
    }),

  getById: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      return null as User | null;
    }),

  create: publicProcedure
    .input(CreateUserInput)
    .mutation(async ({ input, ctx }) => {
      if (!ctx.userId) {
        throw new TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Must be logged in to create users',
        });
      }
      const newUser: User = {
        id: crypto.randomUUID(),
        ...input,
        avatar: undefined,
        createdAt: new Date(),
      };
      return newUser;
    }),

  update: publicProcedure
    .input(UpdateUserInput)
    .mutation(async ({ input, ctx }) => {
      if (!ctx.userId) {
        throw new TRPCError({ code: 'UNAUTHORIZED' });
      }
      const { id, ...updates } = input;
      return { id, ...updates, createdAt: new Date() } as User;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ input }) => {
      return { success: true, id: input.id };
    }),
});

const appRouter = router({
  user: userRouter,
});

type AppRouter = typeof appRouter;

export { appRouter, type AppRouter };
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';

const client = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
    }),
  ],
});

async function demo() {
  const users = await client.user.list.query({
    page: 1,
    pageSize: 10,
    sortBy: 'name',
    sortOrder: 'asc',
  });

  const user = await client.user.getById.query({
    id: '550e8400-e29b-41d4-a716-446655440000',
  });

  const created = await client.user.create.mutate({
    name: '張三',
    email: 'zhang@example.com',
    age: 28,
    role: 'admin',
  });

  const updated = await client.user.update.mutate({
    id: created.id,
    name: '李四',
  });

  await client.user.delete.mutate({
    id: created.id,
  });
}

模式6:生產API校驗中介軟體

import { z, ZodSchema, ZodError } from 'zod';
import { Request, Response, NextFunction } from 'express';

interface ValidationSchemas {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

function validate(schemas: ValidationSchemas) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      if (schemas.body) {
        req.body = schemas.body.parse(req.body);
      }
      if (schemas.query) {
        req.query = schemas.query.parse(req.query) as any;
      }
      if (schemas.params) {
        req.params = schemas.params.parse(req.params) as any;
      }
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        res.status(400).json({
          code: 400,
          message: '校驗失敗',
          details: error.errors.map((e) => ({
            path: e.path.join('.'),
            message: e.message,
            code: e.code,
          })),
        });
        return;
      }
      next(error);
    }
  };
}

const CreateUserBody = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  tags: z.array(z.string()).optional().default([]),
});

const PaginationQuery = z.object({
  page: z.coerce.number().int().positive().default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
  sortBy: z.string().optional(),
  sortOrder: z.enum(['asc', 'desc']).default('asc'),
});

const UserIdParams = z.object({
  id: z.string().uuid(),
});

app.post('/api/users', validate({ body: CreateUserBody }), (req, res) => {
  const body: z.infer<typeof CreateUserBody> = req.body;
  res.json({ code: 0, data: body });
});

app.get('/api/users', validate({ query: PaginationQuery }), (req, res) => {
  const query: z.infer<typeof PaginationQuery> = req.query as any;
  res.json({ code: 0, data: { items: [], ...query } });
});

app.get('/api/users/:id', validate({ params: UserIdParams }), (req, res) => {
  const { id }: z.infer<typeof UserIdParams> = req.params as any;
  res.json({ code: 0, data: { id } });
});

interface ApiError {
  code: number;
  message: string;
  details?: Array<{ path: string; message: string; code: string }>;
}

function formatZodError(error: ZodError): ApiError {
  return {
    code: 400,
    message: '校驗失敗',
    details: error.errors.map((e) => ({
      path: e.path.join('.'),
      message: e.message,
      code: e.code,
    })),
  };
}

const errorMap: z.ZodErrorMap = (issue, ctx) => {
  switch (issue.code) {
    case z.ZodIssueCode.invalid_type:
      return { message: `期望 ${issue.expected},收到 ${issue.received}` };
    case z.ZodIssueCode.too_small:
      return { message: `值不能小於 ${issue.minimum}` };
    case z.ZodIssueCode.too_big:
      return { message: `值不能大於 ${issue.maximum}` };
    case z.ZodIssueCode.invalid_string:
      if (issue.validation === 'email') return { message: '信箱格式不正確' };
      if (issue.validation === 'uuid') return { message: 'UUID格式不正確' };
      if (issue.validation === 'url') return { message: 'URL格式不正確' };
      return { message: '字串格式不正確' };
    default:
      return { message: ctx.defaultError };
  }
};

z.setErrorMap(errorMap);

const RateLimitSchema = z.object({
  windowMs: z.number().int().positive().default(60000),
  maxRequests: z.number().int().positive().default(100),
});

function rateLimiter(options: unknown) {
  const config = RateLimitSchema.parse(options);
  const hits = new Map<string, number[]>();

  return (req: Request, res: Response, next: NextFunction) => {
    const key = req.ip ?? 'unknown';
    const now = Date.now();
    const windowStart = now - config.windowMs;
    const userHits = (hits.get(key) ?? []).filter((t) => t > windowStart);

    if (userHits.length >= config.maxRequests) {
      res.status(429).json({ code: 429, message: '請求過於頻繁' });
      return;
    }

    userHits.push(now);
    hits.set(key, userHits);
    next();
  };
}

常見陷阱

陷阱1:信任API回應不校驗

// ❌ 直接使用API回應,型別斷言掩蓋了執行時風險
const response = await fetch('/api/users');
const users = (await response.json()) as User[];

// ✅ 使用Schema校驗API回應
const response = await fetch('/api/users');
const raw = await response.json();
const result = z.array(UserSchema).safeParse(raw);
if (!result.success) {
  throw new Error(`API回應校驗失敗: ${result.error.message}`);
}
const users: User[] = result.data;

陷阱2:Schema與Interface重複定義

// ❌ 維護兩份定義,極易不一致
interface User {
  id: string;
  name: string;
  email: string;
}
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
});

// ✅ 從Schema推導型別,單一資料來源
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;

陷阱3:忽略safeParse直接parse

// ❌ parse拋出例外,未捕獲會導致應用崩潰
const user = UserSchema.parse(unknownData);

// ✅ safeParse回傳結果物件,安全處理校驗失敗
const result = UserSchema.safeParse(unknownData);
if (result.success) {
  const user = result.data;
} else {
  console.error('校驗失敗:', result.error.flatten());
}

陷阱4:JSON.parse後不做校驗

// ❌ JSON.parse回傳any,型別斷言不提供執行時保障
const config = JSON.parse(localStorage.getItem('appConfig')!) as AppConfig;

// ✅ JSON.parse後用Schema校驗
const raw = localStorage.getItem('appConfig');
if (raw) {
  const result = AppConfigSchema.safeParse(JSON.parse(raw));
  if (result.success) {
    const config: AppConfig = result.data;
  }
}

陷阱5:過度校驗導致效能問題

// ❌ 在熱路徑上使用複雜的巢狀Schema校驗
function processItem(item: unknown) {
  const validated = DeepNestedSchema.parse(item); // 每次呼叫都完整校驗
}

// ✅ 快取Schema,使用寬鬆校驗+嚴格校驗分層
const LightItemSchema = z.object({ id: z.string(), type: z.string() });
const StrictItemSchema = z.object({
  id: z.string().uuid(),
  type: z.enum(['a', 'b', 'c']),
  data: z.record(z.string(), z.unknown()),
  metadata: z.array(MetadataSchema),
});

function processItem(item: unknown) {
  const light = LightItemSchema.parse(item);
  if (needsStrictValidation(light.type)) {
    return StrictItemSchema.parse(item);
  }
  return light;
}

錯誤排查表

錯誤現象 可能原因 解決方案
ZodError: Expected string, received undefined 可選欄位未標記.optional() 新增.optional().default()
Type 'string' is not assignable to type '...' Schema推導型別與手動型別衝突 刪除手動interface,統一用z.infer
io-ts decode returns Left with empty context 輸入值型別與Codec不匹配 檢查err.context鏈路定位具體欄位
Value.Check returns false but no errors TypeBox校驗失敗但錯誤資訊被吞 使用Value.Errors()取得詳細錯誤
valibot safeParse returns success but output is wrong .transform()改變了輸出型別但未調整Schema 檢查InferOutputInferInput的差異
tRPC input validation not triggering procedure未鏈式呼叫.input() 確保.input().query()/.mutation()之前
Schema.parse() throws instead of returning result 使用了parse()而非safeParse() 改用safeParse()處理不確定輸入
z.coerce not converting query params Express query參數為string[]型別 使用z.preprocess()手動轉換
Branded type lost after serialization Branded型別在JSON序列化後遺失 在反序列化時重新parse()恢復Brand
Circular schema reference Schema互相參照導致循環 使用z.lazy()Type.Rec()處理遞迴結構

進階優化

Schema複用與組合

import { z } from 'zod';

const BaseFields = z.object({
  id: z.string().uuid(),
  createdAt: z.date(),
  updatedAt: z.date(),
});

const UserName = z.string().min(2).max(50);
const UserEmail = z.string().email();

const CreateUserSchema = z.object({
  name: UserName,
  email: UserEmail,
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
});

const UserSchema = BaseFields.merge(CreateUserSchema);
type User = z.infer<typeof UserSchema>;

const DiscriminatedUnionSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('email'),
    address: z.string().email(),
    subject: z.string(),
  }),
  z.object({
    type: z.literal('sms'),
    phone: z.string().regex(/^\+?\d{10,15}$/),
    message: z.string().max(160),
  }),
  z.object({
    type: z.literal('push'),
    deviceId: z.string().uuid(),
    title: z.string(),
    body: z.string(),
  }),
]);

type Notification = z.infer<typeof DiscriminatedUnionSchema>;

function sendNotification(notification: Notification) {
  switch (notification.type) {
    case 'email':
      console.log('發送郵件到:', notification.address);
      break;
    case 'sms':
      console.log('發送簡訊到:', notification.phone);
      break;
    case 'push':
      console.log('推送通知到:', notification.deviceId);
      break;
  }
}

const RecursiveCommentSchema: z.ZodType<Comment> = z.lazy(() =>
  z.object({
    id: z.string().uuid(),
    content: z.string(),
    author: z.string(),
    replies: z.array(RecursiveCommentSchema).default([]),
  }),
);

interface Comment {
  id: string;
  content: string;
  author: string;
  replies: Comment[];
}

條件校驗與跨欄位聯動

import { z } from 'zod';

const PaymentSchema = z.object({
  method: z.enum(['credit_card', 'bank_transfer', 'crypto']),
  amount: z.number().positive(),
  currency: z.string().length(3),
}).refine(
  (data) => {
    if (data.method === 'crypto' && data.currency !== 'BTC' && data.currency !== 'ETH') {
      return false;
    }
    return true;
  },
  { message: '加密貨幣支付僅支援BTC或ETH', path: ['currency'] },
);

const RegistrationSchema = z.object({
  username: z.string().min(3).max(20),
  password: z.string().min(8),
  confirmPassword: z.string(),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  parentConsent: z.boolean().optional(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: '兩次輸入的密碼不一致', path: ['confirmPassword'] },
).refine(
  (data) => {
    if (data.age < 18) {
      return data.parentConsent === true;
    }
    return true;
  },
  { message: '未成年人需要家長同意', path: ['parentConsent'] },
);

type Registration = z.infer<typeof RegistrationSchema>;

const ShippingSchema = z.object({
  country: z.string(),
  province: z.string(),
  city: z.string(),
  address: z.string().min(5),
  zipCode: z.string(),
}).refine(
  (data) => {
    if (data.country === 'TW') {
      return /^\d{3}(\d{2})?$/.test(data.zipCode);
    }
    return true;
  },
  { message: '郵遞區號格式不正確', path: ['zipCode'] },
);

效能優化與快取策略

import { z } from 'zod';

const cachedSchemas = new Map<string, z.ZodSchema>();

function getOrCreateSchema<T>(key: string, factory: () => z.ZodSchema<T>): z.ZodSchema<T> {
  if (!cachedSchemas.has(key)) {
    cachedSchemas.set(key, factory());
  }
  return cachedSchemas.get(key) as z.ZodSchema<T>;
}

const DynamicFilterSchema = getOrCreateSchema('dynamicFilter', () =>
  z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
);

const BatchValidationSchema = z.array(z.unknown());

function batchValidate<T>(schema: z.ZodSchema<T>, items: unknown[]): {
  valid: T[];
  invalid: Array<{ index: number; error: z.ZodError }>;
} {
  const valid: T[] = [];
  const invalid: Array<{ index: number; error: z.ZodError }> = [];

  for (let i = 0; i < items.length; i++) {
    const result = schema.safeParse(items[i]);
    if (result.success) {
      valid.push(result.data);
    } else {
      invalid.push({ index: i, error: result.error });
    }
  }

  return { valid, invalid };
}

const LightUserSchema = z.object({
  id: z.string(),
  name: z.string(),
});

const FullUserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  tags: z.array(z.string()),
  metadata: z.record(z.string(), z.unknown()),
});

function validateUserLevel(data: unknown, strict: boolean) {
  const schema = strict ? FullUserSchema : LightUserSchema;
  return schema.safeParse(data);
}

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

function loadEnv() {
  const result = EnvSchema.safeParse(process.env);
  if (!result.success) {
    console.error('環境變數校驗失敗:', result.error.flatten());
    process.exit(1);
  }
  return result.data;
}

const env = loadEnv();
console.log('環境配置載入成功:', env.NODE_ENV, env.PORT);

方案對比

特性 Zod TypeBox io-ts valibot ajv
套件體積 ~13KB ~12KB ~8KB ~3KB ~30KB
型別推導 z.infer<> Static<> t.TypeOf<> v.InferOutput<> 無原生支援
JSON Schema輸出 需zod-to-json-schema 原生輸出 需io-ts-to-json-schema 需valibot-to-json-schema 原生消費
錯誤處理 ZodError Value.Errors Either issues陣列 ValidationError
函數式風格 是(fp-ts)
非同步校驗 .refine() async .check() async 自定義keyword
學習曲線
生態成熟度 ★★★★★ ★★★★ ★★★ ★★★ ★★★★★
適用場景 通用首選 JSON Schema互操作 函數式專案 輕量/瀏覽器 JSON Schema標準校驗

執行時型別校驗不是「錦上添花」,而是TypeScript生產應用的「安全網」。 選擇Zod獲得最佳開發體驗,選擇TypeBox對接JSON Schema生態,選擇io-ts擁抱函數式程式設計,選擇valibot追求極致輕量,選擇ajv獲得JSON Schema標準合規。無論選哪個,永遠不要信任未經校驗的外部資料——這是2026年TypeScript工程師的基本素養。


推薦工具

  • JSON格式化 — 校驗和格式化API回應JSON,快速排查資料結構問題
  • Base64編解碼 — 解碼JWT Token載荷,校驗Token中的型別資料
  • 雜湊計算 — 計算Schema指紋,偵測API契約變更

本站提供瀏覽器本地工具,免註冊即可試用 →

#TypeScript#运行时校验#Zod#TypeBox#类型安全#2026#io-ts