3.1 为什么 tRPC 选择 Zod
tRPC 不强制要求使用 Zod,但 Zod 是默认推荐的验证库。原因在于 Zod 与 TypeScript 的深度融合:一个 Zod Schema 既是运行时验证规则,也是 TypeScript 类型定义,两者完全同步,零额外维护成本。
import { z } from 'zod';
// 定义一次 Schema
const UserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(0).max(150).optional(),
});
// 自动推断 TypeScript 类型
type User = z.infer<typeof UserSchema>;
// ^ { name: string; email: string; age?: number | undefined }
// 运行时验证
const result = UserSchema.safeParse({ name: 'a', email: 'not-email' });
// result.success === false,result.error 包含详细错误信息
TS
3.2 .input():输入 Schema 绑定
在 Procedure 上调用 .input(zodSchema) 来声明输入验证。tRPC 会在执行 handler 之前自动运行验证,验证失败时自动抛出 BAD_REQUEST 错误,无需手动处理。
// .input() 同时提供:
// 1. 运行时验证:请求到达时自动校验
// 2. 类型推断:input 参数类型完全正确
createPost: publicProcedure
.input(z.object({
title: z.string().min(1, '标题不能为空').max(200, '标题最多200字'),
content: z.string().min(10, '内容至少10个字符'),
tags: z.array(z.string()).max(5, '最多5个标签').default([]),
}))
.mutation(async ({ input }) => {
// input 类型完全推断,IDE 有完整提示
// input.title: string
// input.content: string
// input.tags: string[] (有默认值,不是 undefined)
return await db.post.create({ data: input });
}),
TS
3.3 Zod 核心类型详解
基础类型
const schema = z.object({
// 字符串
name: z.string(),
email: z.string().email(),
url: z.string().url(),
uuid: z.string().uuid(),
slug: z.string().regex(/^[a-z0-9-]+$/),
// 数字
age: z.number().int().min(0).max(150),
price: z.number().positive(),
rating: z.number().min(1).max(5),
// 布尔
isActive: z.boolean(),
// 枚举
role: z.enum(['user', 'admin', 'moderator']),
status: z.nativeEnum(StatusEnum), // TypeScript enum
// 日期
createdAt: z.date(),
dateStr: z.string().datetime(), // ISO 8601 字符串
});
TS
复合类型
// 数组
const tagsSchema = z.array(z.string()).min(1).max(10);
// 联合类型(Union)
const idSchema = z.union([z.string(), z.number()]);
// 或使用更简洁的 .or()
const idSchema2 = z.string().or(z.number());
// 可辨别联合(Discriminated Union)
const notificationSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), to: z.string().email() }),
z.object({ type: z.literal('sms'), phone: z.string() }),
]);
// 交叉类型(Intersection)
const adminSchema = z.intersection(UserSchema, z.object({
permissions: z.array(z.string()),
}));
// 可选与默认值
const schema = z.object({
name: z.string(),
bio: z.string().optional(), // string | undefined
avatar: z.string().nullable(), // string | null
theme: z.string().default('light'), // 有默认值,undefined 时使用默认
});
TS
数据转换(Transform)
// transform 在验证通过后转换数据
const trimmedString = z.string().transform(s => s.trim());
// 字符串转数字
const numericString = z.string().transform(Number).pipe(z.number());
// 复杂转换:输入 CSV 字符串,输出数组
const csvToArray = z.string().transform(s => s.split(',').map(x => x.trim()));
// URL 查询参数(字符串)转换为数字
const pageSchema = z.coerce.number().int().min(1).default(1);
// z.coerce.number() 会自动将 "5" 转换为 5
TS
3.4 .output():输出验证(可选但推荐)
tRPC 也支持对 Procedure 的返回值进行验证。这在以下情况特别有用:确保敏感字段(如密码哈希)不会意外泄漏,或确保数据库返回值符合预期格式。
// 定义返回值 Schema,敏感字段不包含在内
const UserOutputSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.date(),
// passwordHash 不在 Schema 中 → 不会出现在返回值里
});
getMe: publicProcedure
.output(UserOutputSchema) // 声明返回值 Schema
.query(async ({ ctx }) => {
const user = await db.user.findUnique({ where: { id: ctx.user.id } });
// 即使数据库返回了 passwordHash,output 验证会过滤掉
return user;
}),
TS
output() 的性能考量每次请求都会对返回数据运行 Zod 验证,对大型数组可能有性能影响。建议只在安全敏感的 Procedure 上使用 .output(),而不是所有接口都加。
3.5 自定义错误消息(中文提示)
Zod 的错误消息默认是英文。可以通过以下两种方式实现中文错误提示:
方法一:内联自定义消息
const RegisterSchema = z.object({
username: z.string()
.min(3, '用户名至少3个字符')
.max(20, '用户名最多20个字符')
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'),
email: z.string().email('请输入有效的邮箱地址'),
password: z.string()
.min(8, '密码至少8位')
.regex(/[A-Z]/, '密码必须包含大写字母')
.regex(/[0-9]/, '密码必须包含数字'),
confirmPassword: z.string(),
age: z.number({ invalid_type_error: '年龄必须是数字' })
.min(18, '必须年满18周岁')
.max(120),
}).refine(
data => data.password === data.confirmPassword,
{ message: '两次输入的密码不一致', path: ['confirmPassword'] }
);
TS
方法二:全局设置中文错误(z.setErrorMap)
import { z, ZodIssueCode } from 'zod';
z.setErrorMap((issue, ctx) => {
switch (issue.code) {
case ZodIssueCode.too_small:
return { message: `至少需要 ${issue.minimum} 个字符` };
case ZodIssueCode.too_big:
return { message: `最多允许 ${issue.maximum} 个字符` };
case ZodIssueCode.invalid_string:
if (issue.validation === 'email') return { message: '邮箱格式不正确' };
if (issue.validation === 'url') return { message: 'URL 格式不正确' };
break;
case ZodIssueCode.invalid_type:
return { message: `期望 ${issue.expected} 类型,收到 ${issue.received}` };
}
return { message: ctx.defaultError };
});
TS
3.6 实战:用户注册与登录验证
// server/routers/auth.ts
import { z } from 'zod';
import { hash, compare } from 'bcryptjs';
import { TRPCError } from '@trpc/server';
import { router, publicProcedure } from '../trpc';
import { db } from '../db';
// 密码强度 Schema(可复用)
const passwordSchema = z.string()
.min(8, '密码至少8位')
.max(100)
.regex(/[A-Z]/, '需要至少一个大写字母')
.regex(/[0-9]/, '需要至少一个数字');
export const authRouter = router({
register: publicProcedure
.input(z.object({
name: z.string().min(2, '姓名至少2个字符').max(50),
email: z.string().email('请输入有效邮箱').toLowerCase(),
password: passwordSchema,
confirmPassword: z.string(),
}).refine(
d => d.password === d.confirmPassword,
{ message: '两次密码不一致', path: ['confirmPassword'] }
))
.mutation(async ({ input }) => {
// 检查邮箱是否已注册
const exists = await db.user.findUnique({
where: { email: input.email }
});
if (exists) {
throw new TRPCError({
code: 'CONFLICT',
message: '该邮箱已被注册',
});
}
// 加密密码
const passwordHash = await hash(input.password, 12);
// 创建用户
const user = await db.user.create({
data: { name: input.name, email: input.email, passwordHash },
select: { id: true, name: true, email: true }, // 不返回 passwordHash
});
return user;
}),
login: publicProcedure
.input(z.object({
email: z.string().email().toLowerCase(),
password: z.string().min(1, '请输入密码'),
}))
.mutation(async ({ input }) => {
const user = await db.user.findUnique({ where: { email: input.email } });
// 统一错误消息,防止用户枚举
const invalidError = new TRPCError({
code: 'UNAUTHORIZED',
message: '邮箱或密码错误',
});
if (!user) throw invalidError;
const valid = await compare(input.password, user.passwordHash);
if (!valid) throw invalidError;
return { id: user.id, name: user.name, email: user.email };
}),
});
TS
本章小结Zod 是 tRPC 的最佳验证搭档:Schema 即类型,一次定义同时完成运行时校验和 TypeScript 类型推断。.input() 自动验证请求输入,.output() 可选地验证返回值(防止敏感字段泄漏)。.refine() 支持跨字段联合验证(如确认密码)。使用内联消息或 z.setErrorMap() 实现全局中文错误提示。