Chapter 03

输入验证(Zod 深度集成)

一次定义,同时获得运行时验证和 TypeScript 类型推断

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() 实现全局中文错误提示。