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, 'Invalid email format');
}),
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, 'Must be a positive integer');
}),
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: 'Validation failed',
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: 'Validation failed',
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: 'Too many requests' });
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 === 'CN') {
return /^\d{6}$/.test(data.zipCode);
}
if (data.country === 'US') {
return /^\d{5}(-\d{4})?$/.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