Zod:TS 之外,运行时也要校验数据

起因

TypeScript 是编译时类型检查。运行时 API 返回数据 / 用户输入 / localStorage
读出来的东西,TS 不知道:

type User = { id: number; email: string };

const res = await fetch('/api/me');
const user: User = await res.json();    // ⚠️ 类型断言不验运行时

user.email.toLowerCase();   // 万一 API 返回 null 或者 email 是 undefined?

类型 lie → 运行时崩。

Zod 是 runtime schema validation + 自动 TS 类型推导。一份 schema 既
是验证 + 又是 type。

基本

import { z } from 'zod';

const UserSchema = z.object({
    id: z.number(),
    email: z.string().email(),
    name: z.string().min(1).max(100),
    age: z.number().int().positive().optional(),
});

type User = z.infer<typeof UserSchema>;
// { id: number; email: string; name: string; age?: number }

const data = await res.json();
const user = UserSchema.parse(data);    // throw if invalid
// user 类型 = User,且运行时保证符合

一份 schema 解决:
- 运行时验证
- TS 类型(infer)
- 错误信息(with 路径)

复杂 schema

const PostSchema = z.object({
    id: z.string().uuid(),
    title: z.string(),
    body: z.string(),
    status: z.enum(['draft', 'published', 'archived']),
    tags: z.array(z.string()),
    author: z.object({
        name: z.string(),
        avatar: z.string().url().optional(),
    }),
    createdAt: z.string().datetime(),
});

嵌套 object + array + enum + format check(uuid / url / datetime)。

transform

const DateSchema = z.string().datetime().transform((s) => new Date(s));
// input: string
// output: Date

const result = DateSchema.parse('2025-03-14T10:00:00Z');
// result: Date instance

parse 时把 string 自动转 Date。
input / output type 不同(z.infer 拿 output)。

refine (自定义校验)

const passwordSchema = z
    .string()
    .min(8)
    .refine((s) => /[A-Z]/.test(s), { message: 'Need uppercase' })
    .refine((s) => /\d/.test(s), { message: 'Need digit' });

错误处理

const result = UserSchema.safeParse(data);
if (!result.success) {
    console.log(result.error.format());
    // {
    //   email: { _errors: ['Invalid email'] },
    //   age: { _errors: ['Expected positive number'] }
    // }
    return;
}
console.log(result.data);    // 通过的 typed data

safeParse 不 throw,返回 result object。
form 验证场景常用。

跟 React Hook Form 集成

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

const FormSchema = z.object({
    email: z.string().email(),
    password: z.string().min(8),
});

function LoginForm() {
    const { register, handleSubmit, formState: { errors } } = useForm({
        resolver: zodResolver(FormSchema),
    });

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

form 校验自动用 Zod schema → message 自动显。

API 验证 (tRPC / Hono)

// tRPC procedure
import { z } from 'zod';

const userRouter = t.router({
    create: t.procedure
        .input(z.object({
            email: z.string().email(),
            name: z.string().min(1),
        }))
        .mutation(({ input }) => {
            // input 类型 + 运行时都符合
            return db.user.create({ data: input });
        }),
});
// Hono
app.post('/users', zValidator('json', UserSchema), (c) => {
    const user = c.req.valid('json');     // typed
    return c.json(user);
});

backend 入口验证 input → 后续 code 类型 + runtime 一致。

与 yup / joi 对比

Zod yup joi
TS 类型推导 ✅ first-class
bundle 中(14 KB)
API builder builder builder
生态 大(现代) 后端流行
性能

TS 项目 → Zod。
JS 老项目 → yup。
Node API server 不要 TS → joi。

与 valibot / arktype 对比

新一代竞争:

  • valibot:tree-shakable,bundle 极小(500B-2KB)
  • arktype:TS-as-schema(直接写 TS 类型当 schema),运行时极快
// valibot
import { object, string, number } from 'valibot';
const UserSchema = object({
    email: string([email()]),
    age: number([integer()]),
});
// arktype
import { type } from 'arktype';
const UserSchema = type({
    email: 'email',
    'age?': 'number.integer>0',
});

valibot 适合极致 bundle size(移动 web)。
arktype 适合纯 TS 语法控。
Zod 仍最普及 + 生态最大。

ecosystem

zod 生态丰富:

  • @hookform/resolvers/zod
  • zod-to-openapi:自动 OpenAPI 3 spec
  • zod-to-ts:生成 TS file
  • prisma-zod-generator:从 Prisma schema 生成 Zod
  • zod-form-data:multipart form 验证

性能

百万次 parse benchmark:

ops/sec
Zod 350k
valibot 800k
arktype 1500k
yup 100k

Zod 不是最快但够用。极致性能场景才换。

真实 case:API 返回防御

API 三方 / 老接口经常返回奇怪:

// API 文档说返回 number,实际偶尔 null
const result = await fetch('/old/api').then(r => r.json());

// 防御
const ResultSchema = z.object({
    count: z.number().nullable().transform((n) => n ?? 0),
    items: z.array(ItemSchema),
});

const safe = ResultSchema.parse(result);
// safe.count 永远 number(null 转 0)

避免下游 result.count + 5 报 NaN。

复杂 union

const EventSchema = z.discriminatedUnion('type', [
    z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
    z.object({ type: z.literal('input'), value: z.string() }),
    z.object({ type: z.literal('submit'), formId: z.string() }),
]);

const event = EventSchema.parse(data);
if (event.type === 'click') {
    event.x;     // typed
}

discriminatedUnion 比 plain union 快 + type narrowing 更准。

env 变量验证

const envSchema = z.object({
    DATABASE_URL: z.string().url(),
    PORT: z.coerce.number().int().positive(),
    NODE_ENV: z.enum(['development', 'production', 'test']),
});

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

启动时验证 env,缺 / 错立刻报。
之后 code 用 env.PORT 类型 number 不是 string。

踩过的坑

  1. z.string().uuid() too strict:传统 v1 UUID 不过。.uuid()
    严格 v4 格式。

  2. .parse vs .safeParse:parse throw + safeParse 返 result。
    form / API boundary 用 safeParse 控制响应;trust source 用 parse
    简洁。

  3. infer 大 schema 慢:深嵌套 union 等 TS 编译慢。break into
    smaller schema。

  4. transform 后 input type 隐z.infer<typeof schema> 拿 output;
    z.input<typeof schema> 拿 input。文档 / API 注意区分。

  5. 生产 bundle 大:Zod 14 KB gzip 在 mobile critical 可能多。
    考虑 valibot 替换关键路径。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。