TypeScript執行時型別校驗實戰:從Zod到TypeBox的6種生產模式
前端工程
TypeScript的型別在執行時就是空氣
你精心定義了interface User,編譯零錯誤,線上卻炸了——API回傳了null代替string,第三方SDK的型別宣告和實際行為完全不一致,JSON.parse()的回傳值永遠是any。TypeScript的型別在編譯後就被完全抹除,執行時沒有任何保障。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大挑戰
- 型別抹除陷阱:TypeScript編譯後型別資訊完全消失,
JSON.parse()、API回應、第三方函式庫回傳值無法保證型別安全 - Schema與型別割裂:手動維護interface和校驗邏輯兩份程式碼,極易不一致,改了interface忘了改校驗
- 錯誤處理不統一:不同校驗函式庫的錯誤格式各異,ZodError、io-ts的Either、ajv的ValidationError,難以統一處理
- 效能與套件體積權衡:Zod功能強大但體積較大,io-ts學習曲線陡峭,valibot輕量但生態不成熟
- 跨語言互操作:微服務架構下前後端、不同服務間需要共享資料契約,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 |
檢查InferOutput與InferInput的差異 |
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工程師的基本素養。
推薦工具
本站提供瀏覽器本地工具,免註冊即可試用 →
#TypeScript#运行时校验#Zod#TypeBox#类型安全#2026#io-ts