起因
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/zodzod-to-openapi:自动 OpenAPI 3 speczod-to-ts:生成 TS fileprisma-zod-generator:从 Prisma schema 生成 Zodzod-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。
踩过的坑
-
z.string().uuid() too strict:传统 v1 UUID 不过。
.uuid()是
严格 v4 格式。 -
.parse vs .safeParse:parse throw + safeParse 返 result。
form / API boundary 用 safeParse 控制响应;trust source 用 parse
简洁。 -
infer 大 schema 慢:深嵌套 union 等 TS 编译慢。break into
smaller schema。 -
transform 后 input type 隐:
z.infer<typeof schema>拿 output;
z.input<typeof schema>拿 input。文档 / API 注意区分。 -
生产 bundle 大:Zod 14 KB gzip 在 mobile critical 可能多。
考虑 valibot 替换关键路径。
登录后参与评论。