TypeScript 运行时验证:Zod 在 API 和表单中的实战应用

TypeScript 只在编译时有类型检查,运行时数据仍需验证。详解 Zod 在 API 请求验证、表单校验、环境变量类型安全、Prisma 数据校验中的完整用法。

TypeScript 的类型在编译后完全消失,运行时 incoming 数据(API 请求、表单输入、环境变量)可能是任意值。Zod 是在 TypeScript 生态中最流行的运行时类型验证库——它与 TS 类型系统天然配合,用声明式的方式定义数据 schema,并自动推断出对应的 TS 类型。


一、Zod 基础

npm install zod

1.1 Schema 定义与类型推断

Zod 的核心哲学是「一份 schema,双重收益」。你只用写一次验证规则,Zod 就能在运行时校验数据,同时在编译时自动推导出 TypeScript 类型。这种「单源真理」的设计避免了类型定义与验证逻辑不同步的问题。

import { z } from 'zod';

// 定义 schema
const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).optional(),
  role: z.enum(['user', 'admin']),
});

// 自动推断 TypeScript 类型
type User = z.infer<typeof UserSchema>;
// 等同于:
// type User = { id: number; name: string; email: string; age?: number; role: 'user' | 'admin' }

// 运行时验证
const result = UserSchema.safeParse(unknownData);
if (!result.success) {
  console.log(result.error.issues); // 详细的验证错误
} else {
  const user: User = result.data;
}

1.2 强制类型转换(Coercions)

Web 表单和 URL Query 参数本质上都是字符串,但业务逻辑往往期望数字或布尔值。Zod 提供 coerce 系列方法,在验证的同时完成类型转换,省去手写 Number() 或 Boolean() 的样板代码。

const QuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  isActive: z.coerce.boolean().default(true),
  tags: z.string().transform((s) => s.split(',')).optional(),
});

QuerySchema.parse({ page: '3', limit: '50', isActive: 'true' });
// => { page: 3, limit: 50, isActive: true }

z.coerce.number() 内部会调用 Number(value),因此对 '42'、42、甚至 ' 42 ' 都能正确解析,但 'abc' 会被优雅地拦截下来并返回结构化的错误信息。

1.3 Transforms 与 Refinements

当内置验证器不够用的时候,transform 和 refine 两个 API 可以让你在 schema 链中嵌入任意逻辑。

const PasswordSchema = z
  .string()
  .min(8)
  .refine(
    (val) => /[A-Z]/.test(val) && /[a-z]/.test(val) && /\d/.test(val),
    {
      message: '密码必须包含大小写字母和数字',
    }
  );

const DateRangeSchema = z
  .object({
    start: z.string().datetime(),
    end: z.string().datetime(),
  })
  .refine((data) => new Date(data.start) < new Date(data.end), {
    message: '结束时间必须晚于开始时间',
    path: ['end'],
  });

// transform 在验证通过后执行数据清洗
const SlugSchema = z
  .string()
  .min(1)
  .max(200)
  .transform((s) =>
    s
      .toLowerCase()
      .replace(/\s+/g, '-')
      .replace(/[^a-z0-9-]/g, '')
  );

refine 适合业务规则的校验,transform 适合数据规范化。两者可以链式组合,但要注意 transform 一旦执行,原始数据就不可恢复了。

1.4 Branded Types(品牌类型)

在大型代码库中,两个结构相同的 primitive 类型(比如 string)可能代表完全不同的语义含义——用户 ID 和订单 ID 都是 string,但绝不能混用。Zod 的 brand 可以在运行时保持原有值,在编译时引入名义类型(nominal typing),防止这种隐式错误。

const UserId = z.string().brand<'UserId'>();
const OrderId = z.string().brand<'OrderId'>();

type UserId = z.infer<typeof UserId>;
type OrderId = z.infer<typeof OrderId>;

function cancelOrder(orderId: OrderId) { /* ... */ }

const uid = UserId.parse('u_123');
cancelOrder(uid); // TypeScript Error: 类型 "string & BRAND<"UserId">" 的参数不能赋给类型 "string & BRAND<"OrderId">" 的参数

Branded types 尤其适用于资金计算、实体 ID、加密哈希值等对类型安全要求极高的场景。

1.5 递归 Schema(Lazy / Recursive Schemas)

目录树、组织架构、评论嵌套等场景都需要自引用类型。Zod 提供 z.lazy() 解决 TypeScript 的循环引用问题。

type TreeNode = {
  id: number;
  label: string;
  children: TreeNode[];
};

const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
  z.object({
    id: z.number(),
    label: z.string(),
    children: z.array(TreeNodeSchema),
  })
);

TreeNodeSchema.parse({
  id: 1,
  label: 'Root',
  children: [{ id: 2, label: 'Child', children: [] }],
});

使用 z.lazy() 时,必须显式标注 z.ZodType<YourType>,否则 TypeScript 的类型推断会陷入死循环。


二、API 请求验证

2.1 Express 中间件

// Express 中间件
import { Request, Response, NextFunction } from 'express';
import { z, ZodError } from 'zod';

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).max(10),
});

function validateBody<T extends z.ZodType>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.issues,
      });
    }
    req.body = result.data;
    next();
  };
}

app.post('/posts', validateBody(CreatePostSchema), (req, res) => {
  // req.body 已验证为 CreatePost 类型
  const { title, content, tags } = req.body;
  // ...
});

2.2 NestJS Pipes 集成

NestJS 原生支持基于 class-validator 的方案,但社区也提供了 nestjs-zod 包,让你可以直接在 DTO 中使用 Zod schema,并享受到自动 Swagger 文档生成的好处。

import { z } from 'zod';
import { createZodDto } from 'nestjs-zod';

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(50),
  age: z.number().int().min(0).optional(),
});

export class CreateUserDto extends createZodDto(CreateUserSchema) {}

// controller.ts
import { Body, Controller, Post } from '@nestjs/common';

@Controller('users')
export class UserController {
  @Post()
  create(@Body() dto: CreateUserDto) {
    // dto 已经通过 Zod 验证
    return this.userService.create(dto);
  }
}

NestJS 的 ValidationPipe 会自动调用 DTO 上的 zodSchema.parse(),不需要你手动在 controller 中写任何验证逻辑。这种方式将验证层与业务逻辑层彻底解耦。

2.3 Fastify 验证

Fastify 通过 preValidation 钩子支持 Zod 集成。结合 @fastify/swagger,可以同时完成请求验证和 API 文档自动生成。

import Fastify from 'fastify';
import { z } from 'zod';

const fastify = Fastify();

const BodySchema = z.object({
  username: z.string().min(3),
  password: z.string().min(8),
});

fastify.post(
  '/register',
  {
    schema: {
      body: BodySchema,
      response: {
        200: z.object({ token: z.string() }),
      },
    },
  },
  async (request, reply) => {
    const body = request.body; // 类型已推断为 { username: string; password: string }
    return { token: await generateToken(body) };
  }
);

Fastify 的 schema-based 架构天然适合 Zod——两者都强调「声明式定义,自动执行」。你可以把 Zod schema 序列化为 JSON Schema 注册到 Fastify,实现零重复代码。

2.4 OpenAPI / Swagger 自动生成

手动维护 Swagger 文档与代码同步是一件痛苦的事。zod-to-openapi 库可以将任意 Zod schema 转换为 OpenAPI Schema Object,再配合路由注册器,实现文档的完全自动生成。

import { extendZodWithOpenApi, OpenApiGeneratorV3 } from 'zod-to-openapi';
import { z } from 'zod';

extendZodWithOpenApi(z);

const UserSchema = z
  .object({ id: z.number().openapi({ example: 42 }), name: z.string().openapi({ example: 'Alice' }) })
  .openapi('User');

const generator = new OpenApiGeneratorV3([UserSchema]);
const docs = generator.generateDocument({
  openapi: '3.0.0',
  info: { title: 'My API', version: '1.0.0' },
});

通过这种方式,后端的 API 文档永远与实际验证规则保持一致,不会出现「文档说可选,代码里 required」的陷阱。


三、环境变量类型安全

3.1 基础 env.ts

// env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.string().transform(Number).default('3000'),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  REDIS_URL: z.string().url().optional(),
});

export const env = envSchema.parse(process.env);
// 如果缺少必需变量或格式错误,启动时直接抛出清晰的错误

3.2 dotenv 文件加载

在本地开发和测试环境中,环境变量通常存放在 .env 文件中。推荐在 env.ts 中显式调用 dotenv 的加载逻辑,并把 .env.example 作为团队共享的模板文件。验证不通过时,Zod 会输出包含具体字段和期望类型的错误信息,让同事一眼就知道缺少哪些配置。

import { config } from 'dotenv';
import { z } from 'zod';
import { resolve } from 'path';

// 根据 NODE_ENV 决定加载哪个 .env 文件
const envFile = process.env.NODE_ENV === 'test' ? '.env.test' : '.env';
config({ path: resolve(process.cwd(), envFile) });

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  API_BASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

export const env = envSchema.parse(process.env);

3.3 CI/CD 环境校验

在生产部署的 CI/CD 流水线中,建议在构建或启动阶段前置执行一次 env.ts 的验证脚本。如果环境变量缺失,整条流水线应该立即失败,而不是让带着错误配置的服务启动后再崩溃。

# .github/workflows/deploy.yml
- name: Validate Environment Variables
  run: npx ts-node scripts/validate-env.ts
// scripts/validate-env.ts
import '../src/env';
console.log('Environment variables validated successfully.');

这样即便运维人员填写了错误的 DATABASE_URL,也能在部署早期被拦截下来,避免线上事故。

3.4 Docker Secrets 集成

在 Docker Swarm 或 Kubernetes 环境中,敏感信息不应以明文环境变量注入,而是通过 Docker Secrets 或挂载文件的方式提供。Zod 的 preprocess 可以读取这些文件内容并注入到 schema 中。

import { readFileSync } from 'fs';
import { z } from 'zod';

function fromSecret(path: string): string {
  try {
    return readFileSync(path, 'utf-8').trim();
  } catch {
    return '';
  }
}

const envSchema = z.object({
  DATABASE_URL: z.preprocess(
    (val) => val || fromSecret('/run/secrets/database_url'),
    z.string().url()
  ),
  JWT_SECRET: z.preprocess(
    (val) => val || fromSecret('/run/secrets/jwt_secret'),
    z.string().min(32)
  ),
});

export const env = envSchema.parse(process.env);

这种分离敏感数据的方式不仅提升了安全性,也让本地开发(.env)与生产部署(Docker Secrets)可以共用同一套验证逻辑。


四、React Hook Form + Zod

4.1 基础表单验证

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email('请输入有效的邮箱'),
  password: z.string().min(8, '密码至少8位'),
});

type FormData = z.infer<typeof schema>;

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register('password')} />
      <button type="submit">登录</button>
    </form>
  );
}

4.2 Next.js App Router Server Actions 表单验证

Next.js 14 引入的 Server Actions 让表单提交可以直接调用服务端函数,无需中间 API 路由。Zod 可以在 Server Action 中对原始表单数据进行服务端二次验证,防止客户端校验被绕过。

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const ContactFormSchema = z.object({
  name: z.string().min(2, '姓名至少2个字符'),
  email: z.string().email('邮箱格式不正确'),
  message: z.string().min(10, '留言内容至少10个字符').max(1000),
});

export async function submitContactForm(formData: FormData) {
  const raw = Object.fromEntries(formData.entries());
  const parsed = ContactFormSchema.safeParse(raw);

  if (!parsed.success) {
    return { success: false, errors: parsed.error.flatten().fieldErrors };
  }

  await db.insert('contacts').values(parsed.data);
  revalidatePath('/contact');
  return { success: true };
}

// page.tsx
// 'use client'
// import { useFormState } from 'react-dom';
// const [state, formAction] = useFormState(submitContactForm, null);

客户端的 Zod + React Hook Form 提供即时反馈体验,Server Action 中的 Zod 验证则构成最后一道安全防线。这种「双重验证」的设计是生产级应用的标配。

4.3 Field Arrays 动态表单

动态表单(比如添加多个工作经历、技能标签)在业务中很常见。React Hook Form 的 useFieldArray 与 Zod 配合时,需要把数组中的每个元素都定义好 schema。

import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  members: z.array(
    z.object({
      name: z.string().min(1, '姓名不能为空'),
      email: z.string().email('邮箱格式错误'),
      role: z.enum(['admin', 'editor', 'viewer']),
    })
  ).min(1, '至少需要一名成员'),
});

type FormData = z.infer<typeof schema>;

function TeamForm() {
  const { register, control, handleSubmit } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: { members: [{ name: '', email: '', role: 'viewer' }] },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'members',
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`members.${index}.name`)} placeholder="姓名" />
          <input {...register(`members.${index}.email`)} placeholder="邮箱" />
          <select {...register(`members.${index}.role`)}>
            <option value="admin">管理员</option>
            <option value="editor">编辑</option>
            <option value="viewer">查看者</option>
          </select>
          <button type="button" onClick={() => remove(index)}>删除</button>
        </div>
      ))}
      <button
        type="button"
        onClick={() => append({ name: '', email: '', role: 'viewer' })}
      >
        添加成员
      </button>
      <button type="submit">提交</button>
    </form>
  );
}

useFieldArray 会自动为每个动态行分配唯一的 id,即使删除中间行也不会影响 Zod 的路径定位。结合 z.array().min() 可以在删除最后一行时阻止提交,UX 与数据完整性兼得。


五、Zod 与 Prisma 联动

ORM 的模型定义与运行时验证 schema 非常容易 diverge:开发者在 Prisma schema 里改了字段名,却忘了同步修改 Zod 的 DTO。社区工具 zod-prisma-types 可以基于 prisma generate 的 AST 自动产出对应的 Zod schema,彻底消除这份维护负担。

// schema.prisma
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
}

在 prisma/schema.prisma 的顶部加上 generator 配置:

generator zod {
  provider = "zod-prisma-types"
  output   = "./zod"
}

执行 npx prisma generate 后,你会在 prisma/zod 目录下得到:PostSchema、PostCreateInputSchema、PostUpdateInputSchema、PostWhereInputSchema 等十多种变体。这些 schema 覆盖了 CRUD 的完整生命周期:

import { PostCreateInputSchema, PostUpdateInputSchema } from './prisma/zod';

// 创建时 title 是必填的
const createData = PostCreateInputSchema.parse({
  title: 'Hello Zod',
  content: 'Prisma + Zod 自动同步',
  author: { connect: { id: 1 } },
});

// 更新时所有字段都是 optional 的
const updateData = PostUpdateInputSchema.parse({
  published: true,
});

当团队迭代数据库结构时,只需修改 Prisma schema 再执行 generate,所有依赖的 Zod schema 会自动重建。这种「代码生成」模式比手动维护要可靠得多。


六、错误国际化与自定义错误消息

面向多语言用户的产品需要把验证错误翻译成不同的 locale。Zod 本身不提供内置的 i18n 系统,但支持通过定制 errorMap 实现完全可控的国际化策略。

6.1 全局 errorMap 定制

import { z } from 'zod';
import i18next from 'i18next';

const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  switch (issue.code) {
    case z.ZodIssueCode.too_small:
      if (issue.type === 'string') {
        return {
          message: i18next.t('validation.minLength', {
            field: issue.path.join('.'),
            min: issue.minimum,
          }),
        };
      }
      break;
    case z.ZodIssueCode.invalid_string:
      if (issue.validation === 'email') {
        return { message: i18next.t('validation.invalidEmail') };
      }
      break;
    case z.ZodIssueCode.invalid_enum_value:
      return {
        message: i18next.t('validation.invalidEnum', {
          options: issue.options.join(', '),
        }),
      };
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(customErrorMap);

安装 zod-i18n-map 库可以省去自己写所有 case 的麻烦。它内置了中文、英文、日文、德文等 20 多种 locale 映射,只需一行配置即可切换语言环境。

6.2 字段级自定义消息

对于不需要完整 i18n 的小型项目,直接在 schema 链上写中文提示是最轻量的方案:

const UserSchema = z.object({
  username: z
    .string({ required_error: '用户名不能为空' })
    .min(3, '用户名至少3个字符')
    .max(20, '用户名不能超过20个字符'),
  email: z.string().email('请输入正确的邮箱地址'),
  phone: z
    .string()
    .regex(/^1[3-9]\d{9}$/, '请输入有效的中国大陆手机号'),
});

当表单字段较多时,可以把这些消息抽离到一个独立的 messages 对象里,既保持 schema 清晰,又方便后期统一调整文案风格。


七、性能优化:大对象预编译 Schema

Zod 的解析过程涉及大量的递归 AST 遍历。对于高频调用的小 schema,这种开销可以忽略不计;但如果你需要在一个批量导入接口里解析上万个复杂对象,性能差距就会显现出来。

7.1 使用 z.preprocess 减少重复计算

z.preprocess 允许你在进入主 schema 之前,先对原始数据做一次轻量的规范化。例如把 JSON 字符串提前解析为对象,避免每个子字段都去判断类型。

const PayloadSchema = z.preprocess(
  (val) => (typeof val === 'string' ? JSON.parse(val) : val),
  z.object({
    items: z.array(z.object({ id: z.number(), qty: z.number() })),
  })
);

7.2 缓存已解析的 Schema

在 Node.js 服务端,module-level 的 schema 定义天然是单例的,不需要额外缓存。但在 Web Worker 或边缘计算(V8 Isolate)场景下,schema 可能会被反复初始化。可以把解析过的 schema 挂在全局对象或一个模块级 Map 上复用。

const schemaCache = new Map<string, z.ZodTypeAny>();

function getCachedSchema(key: string, factory: () => z.ZodTypeAny) {
  if (!schemaCache.has(key)) {
    schemaCache.set(key, factory());
  }
  return schemaCache.get(key)!;
}

如果项目对性能要求极高,也可以考虑 @zod/core 的新实验性编译器(Zod v4 规划),它在构建阶段把 schema 编译为更高效的验证函数,运行时开销接近手写校验代码。


八、测试策略:Mock 数据生成与 Fuzzing 测试

手动编写测试数据既枯燥又容易遗漏边界条件。Zod schema 作为「数据源-验证规则」的权威描述,天然适合反向生成测试数据。

8.1 从 Schema 生成 Mock 数据

zod-mock 库可以遍历 Zod schema 的 AST,根据每个字段的类型和约束自动产生合法的 mock 值。

import { generateMock } from '@anatine/zod-mock';
import { z } from 'zod';

const ProductSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  price: z.number().min(0),
  inStock: z.boolean(),
  tags: z.array(z.string()).max(5),
});

const mockProduct = generateMock(ProductSchema);
// => { id: 123, name: 'mock-string', price: 42, inStock: true, tags: ['tag1'] }

配合 faker 可以从 min、max、email、url 等约束中推断语义,生成更贴近真实业务的数据。例如 z.string().email() 会产出看起来像邮箱的字符串,而不是随机的字符组合。

8.2 Fuzzing 测试——故意传入坏数据

单元测试不仅要验证「好数据通过」,更要验证「坏数据被正确拦截」。可以写一个通用的 fuzzing 辅助函数:

import { z } from 'zod';

function expectInvalid<T extends z.ZodTypeAny>(
  schema: T,
  badValues: unknown[]
) {
  for (const val of badValues) {
    const result = schema.safeParse(val);
    if (result.success) {
      throw new Error(`Expected failure but got success for: ${JSON.stringify(val)}`);
    }
  }
}

// 使用示例
const AgeSchema = z.number().int().min(0).max(120);

expectInvalid(AgeSchema, [
  'not a number',
  -1,
  121,
  3.14,
  null,
  undefined,
  {},
]);

结合 fast-check 等 property-based testing 工具,可以进一步自动生成海量随机输入,让验证逻辑在统计学意义上得到充分覆盖。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. 前端 GraphQL 客户端集成:Apollo Client、Relay 与 urql 选型
  3. 前端性能调试实战:从首屏加载到运行时瓶颈