web-forms-zod-validation

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Zod Schema Validation Patterns

Zod Schema 验证模式

Quick Guide: A schema is declared once and the TypeScript type derived from it with
z.infer
, so the rule and the type cannot drift.
safeParse
returns a result object where invalid input is expected and
parse
throws where it is a bug;
refine
and
superRefine
carry rules the built-in checks cannot express, and
transform
converts during validation — which splits
z.input
from
z.output
. On v4 the string formats moved to the top level (
z.email()
,
z.url()
,
z.iso.*
) and the v3 method chains are deprecated rather than removed;
flatten()
,
format()
and
merge()
have replacements, and reference.md carries the full migration list.
Detailed Resources:
  • examples/core.md — schema definition, safe parsing, error formatting, unions, composition, async refinements
  • examples/transforms.md — transforms, coercion, pipe chains, query params
  • examples/advanced-patterns.md — branded types,
    .catch()
    fallbacks, readonly, recursive schemas
  • reference.md — decision trees, method lookup, worked anti-patterns, v4 migration guide

快速指南: schema 只需声明一次,并通过
z.infer
从中派生 TypeScript 类型, 因此规则与类型不会发生偏离。对于预期会出现无效输入的场景,
safeParse
返回结果对象; 而在无效输入意味着存在 bug 的场景中,
parse
会抛出异常;
refine
和
superRefine
用于 承载内置检查无法表达的规则,
transform
则在验证期间转换值——这会使
z.input
与
z.output
分离。在 v4 中,字符串格式已移至顶层(
z.email()
、
z.url()
、
z.iso.*
), v3 方法链虽已弃用但尚未移除;
flatten()
、
format()
和
merge()
均有替代方案, reference.md 列出了完整的迁移清单。
详细资源:
  • examples/core.md — schema 定义、安全解析、错误格式化、union、组合、异步 refinement
  • examples/transforms.md — transform、coercion、pipe 链、查询参数
  • examples/advanced-patterns.md — branded 类型、
    .catch()
    fallback、readonly、递归 schema
  • reference.md — 决策树、方法查询、常见反模式实例、v4 迁移指南

Which path applies

适用路径

  • The schema checks a value and hands back what it received —
    z.infer
    is the only type helper needed, and the parsed value has the shape the caller passed in. Follow examples/core.md.
  • The schema converts as it checks — a
    transform
    , a
    coerce
    or a
    default
    makes the input and output types differ, so a function taking pre-validation data types its parameter
    z.input
    and its return
    z.output
    . Follow examples/transforms.md.

<critical_requirements>
  • schema 检查一个值并原样返回 — 只需要
    z.infer
    这一个类型辅助工具, 解析后的值具有调用方传入时的结构。请参阅 examples/core.md。
  • schema 在检查的同时进行转换 —
    transform
    、
    coerce
    或
    default
    会使输入类型与 输出类型不同,因此接收验证前数据的函数应将参数类型设为
    z.input
    ,返回值类型设为
    z.output
    。 请参阅 examples/transforms.md。

<critical_requirements>

Before writing Zod schemas

编写 Zod schema 之前

Reach for
safeParse
wherever invalid input is expected.
It returns a result object rather than throwing, so the failure is a branch rather than a catch, and
result.error.issues
carries the field paths a form needs. Keep
parse
for config and internal data, where invalid means a bug.
Derive the type with
z.infer<typeof schema>
.
A hand-written interface beside a schema is a second declaration of the same thing, and the two drift in the direction that leaves the type claiming more than the schema checks.
Validate where untrusted data enters — API responses, form input, config, URL params. A shape change caught at the boundary names the field that moved; caught later it surfaces as an undefined property several frames away.
Name the validation limits.
.min(MIN_USERNAME_LENGTH)
says what the number is for, and the same constant reaches the error message so the two cannot disagree.
</critical_requirements>

Auto-detection: zod, z.object, z.infer, z.input, z.output, safeParse, safeParseAsync, parse, parseAsync, refine, superRefine, ctx.addIssue, transform, discriminatedUnion, z.coerce, z.pipe, z.catch, z.brand, z.lazy, z.email, z.url, z.uuid, z.iso, z.flattenError, z.treeifyError, z.strictObject, z.looseObject, ZodError
Applies to:
  • Validating data crossing a trust boundary, and reporting which field failed
  • Deriving TypeScript types from the rules that enforce them
  • Cross-field rules, conditional shapes and discriminated variants
  • Converting values during validation — strings to numbers, ISO strings to
    Date
  • Composing schemas for the read, create and update shapes of one record
Handled elsewhere:
  • Wiring a schema into a form — a form library accepts one through its own adapter or validator slot, and how that connection is made is settled by whatever owns it.
  • Where the data came from — a schema validates a value it is handed and performs no I/O of its own.
  • Persistence schemas — a runtime validator and a table definition are separate artefacts even where they describe the same record, and generating one from the other is that tool's concern.

<philosophy>
TypeScript checks the code you compile; a schema checks the data you receive. The two meet at the boundary, and the point of deriving the type from the schema is that only one of them can be wrong.
typescript
const UserSchema = z.object({ name: z.string(), email: z.email() });
type User = z.infer<typeof UserSchema>;
Written the other way round — an interface, and a schema maintained beside it — a field added to the interface and forgotten in the schema type-checks everywhere while validating nothing.
The corollary is where not to reach for a schema. Data that has already crossed a boundary has been checked, and re-validating it inside a function the compiler already governs buys nothing and costs a parse on every call.
</philosophy>
<patterns>
只要预期输入可能无效,就使用
safeParse
。
它返回结果对象而不是抛出异常, 因此失败可以通过分支处理,而无需依赖 catch;
result.error.issues
包含表单所需的字段路径。 对于配置和内部数据应保留
parse
,因为这些数据无效意味着存在 bug。
使用
z.infer<typeof schema>
派生类型。
在 schema 旁手写 interface,相当于对同一事物 进行第二次声明,两者最终会产生偏差,导致类型声称的内容多于 schema 实际检查的内容。
在不可信数据进入时进行验证 — API 响应、表单输入、配置、URL 参数。在边界处捕获结构变化, 可以明确指出发生变化的字段;若在更晚阶段才捕获,则问题会表现为若干调用栈帧之外的 undefined 属性。
为验证限制命名。
.min(MIN_USERNAME_LENGTH)
说明了数字的用途,同一常量也用于错误消息, 因此二者不会不一致。
</critical_requirements>

自动检测: zod, z.object, z.infer, z.input, z.output, safeParse, safeParseAsync, parse, parseAsync, refine, superRefine, ctx.addIssue, transform, discriminatedUnion, z.coerce, z.pipe, z.catch, z.brand, z.lazy, z.email, z.url, z.uuid, z.iso, z.flattenError, z.treeifyError, z.strictObject, z.looseObject, ZodError
适用场景:
  • 验证跨越信任边界的数据,并报告失败的字段
  • 根据实际执行验证的规则派生 TypeScript 类型
  • 跨字段规则、条件结构和 discriminated variant
  • 在验证期间转换值——字符串转数字、ISO 字符串转
    Date
  • 为同一条记录的 read、create 和 update 结构组合 schema
在其他位置处理:
  • 将 schema 接入表单——表单库会通过自己的 adapter 或 validator slot 接受一个 schema, 具体连接方式由相应组件决定。
  • 数据的来源——schema 验证交给它的值,本身不执行 I/O。
  • 持久化 schema——runtime validator 与 table definition 是两个独立构件,即使描述同一条记录也是如此; 从其中一个生成另一个属于相应工具的职责。

<philosophy>
TypeScript 检查编译的代码;schema 检查接收到的数据。二者在边界处相遇,而从 schema 派生类型的 意义在于,两者之中只有一个可能出错。
typescript
const UserSchema = z.object({ name: z.string(), email: z.email() });
type User = z.infer<typeof UserSchema>;
换一种写法——先定义 interface,再在旁边维护 schema——如果 interface 中新增了字段却忘记更新 schema, 那么它仍能在各处通过类型检查,却没有验证任何内容。
推论是明确不应使用 schema 的场景。数据一旦跨越边界就已经检查过,在编译器已经管控的函数内部 再次验证它不会带来任何收益,却会为每次调用增加一次解析开销。
</philosophy>
<patterns>

Core patterns

核心模式

Pattern 1: Schema with named limits

模式 1:使用命名限制的 schema

The constant appears in the check and in the message, so a change to the limit updates both.
typescript
const MIN_USERNAME_LENGTH = 3;
const MAX_USERNAME_LENGTH = 50;

const UserSchema = z.object({
  username: z
    .string()
    .min(
      MIN_USERNAME_LENGTH,
      `Username must be at least ${MIN_USERNAME_LENGTH} characters`,
    )
    .max(
      MAX_USERNAME_LENGTH,
      `Username cannot exceed ${MAX_USERNAME_LENGTH} characters`,
    ),
  email: z.email("Invalid email format"),
});

type User = z.infer<typeof UserSchema>;
Full code: examples/core.md Pattern 1

常量同时出现在检查和消息中,因此修改限制时会同步更新两者。
typescript
const MIN_USERNAME_LENGTH = 3;
const MAX_USERNAME_LENGTH = 50;

const UserSchema = z.object({
  username: z
    .string()
    .min(
      MIN_USERNAME_LENGTH,
      `Username must be at least ${MIN_USERNAME_LENGTH} characters`,
    )
    .max(
      MAX_USERNAME_LENGTH,
      `Username cannot exceed ${MAX_USERNAME_LENGTH} characters`,
    ),
  email: z.email("Invalid email format"),
});

type User = z.infer<typeof UserSchema>;
完整代码:examples/core.md 模式 1

Pattern 2: safeParse and error formatting

模式 2:safeParse 与错误格式化

The result is a discriminated union on
success
, so the failure branch narrows to an error and the success branch to typed data.
typescript
const result = UserSchema.safeParse(data);

if (!result.success) {
  const { fieldErrors } = z.flattenError(result.error);
  return { success: false, errors: fieldErrors };
}

return { success: true, user: result.data };
z.flattenError
gives one level of field-to-messages, which is what a flat form needs.
z.treeifyError
preserves nesting for anything deeper.
Full code: examples/core.md Pattern 2

结果是以
success
判别的 union,因此失败分支会收窄为 error,成功分支会收窄为类型化数据。
typescript
const result = UserSchema.safeParse(data);

if (!result.success) {
  const { fieldErrors } = z.flattenError(result.error);
  return { success: false, errors: fieldErrors };
}

return { success: true, user: result.data };
z.flattenError
给出一层字段到消息的映射,适合扁平表单。
z.treeifyError
则为更深层结构保留嵌套关系。
完整代码:examples/core.md 模式 2

Pattern 3: Refinements and cross-field rules

模式 3:refinement 与跨字段规则

refine
adds a condition to one value.
superRefine
sees the whole object and chooses which path the error attaches to, which is what puts a mismatch message on the field the user must fix.
typescript
const PasswordFormSchema = z
  .object({
    password: z.string().min(MIN_PASSWORD_LENGTH),
    confirmPassword: z.string(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Passwords do not match",
        path: ["confirmPassword"],
      });
    }
  });
Full code: examples/core.md Pattern 5

refine
为单个值添加条件。
superRefine
可查看整个对象,并选择错误附加到哪个路径, 从而把不匹配消息放在用户必须修复的字段上。
typescript
const PasswordFormSchema = z
  .object({
    password: z.string().min(MIN_PASSWORD_LENGTH),
    confirmPassword: z.string(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Passwords do not match",
        path: ["confirmPassword"],
      });
    }
  });
完整代码:examples/core.md 模式 5

Pattern 4: Transforms and the input/output split

模式 4:transform 与输入/输出类型分离

Once a schema transforms, it has two types.
z.infer
returns the output, so a function receiving raw data types its parameter
z.input
.
typescript
const DateSchema = z.iso.datetime().transform((str) => new Date(str));

type DateInput = z.input<typeof DateSchema>; // string
type DateOutput = z.output<typeof DateSchema>; // Date
.transform()
runs after every other check on the same schema, so a rule that must inspect the converted value goes after a
.pipe()
rather than before the transform.
Full code: examples/transforms.md Pattern 7, and Pattern 9 for the pipe

一旦 schema 执行 transform,它就有两种类型。
z.infer
返回输出类型,因此接收原始数据的函数 应将其参数类型设为
z.input
。
typescript
const DateSchema = z.iso.datetime().transform((str) => new Date(str));

type DateInput = z.input<typeof DateSchema>; // string
type DateOutput = z.output<typeof DateSchema>; // Date
.transform()
会在同一 schema 的所有其他检查之后运行,因此,需要检查转换后值的规则应放在
.pipe()
之后,而不是 transform 之前。
完整代码:examples/transforms.md 模式 7;pipe 相关内容见模式 9

Pattern 5: Discriminated unions

模式 5:discriminated union

Where the variants share a literal field, naming it lets Zod check one branch instead of all of them.
typescript
const NotificationSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("email"), email: z.email(), subject: z.string() }),
  z.object({ type: z.literal("sms"), phone: z.string(), message: z.string() }),
  z.object({
    type: z.literal("push"),
    deviceId: z.string(),
    title: z.string(),
  }),
]);
A plain
z.union
tries every member and reports the combined failure, which reads as "invalid input"; the discriminated form names the variant and the field inside it, and narrows in a
switch
.
Full code: examples/core.md Pattern 4

当各 variant 共享一个 literal 字段时,指定该字段可让 Zod 只检查一个分支,而不是所有分支。
typescript
const NotificationSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("email"), email: z.email(), subject: z.string() }),
  z.object({ type: z.literal("sms"), phone: z.string(), message: z.string() }),
  z.object({
    type: z.literal("push"),
    deviceId: z.string(),
    title: z.string(),
  }),
]);
普通
z.union
会尝试每个成员并报告组合后的失败信息,看起来只是“无效输入”; 而 discriminated 形式会指出具体的 variant 及其中的字段,并可在
switch
中完成类型收窄。
完整代码:examples/core.md 模式 4

Pattern 6: Composition

模式 6:组合

One base schema, and the read, create and update shapes derived from it — so a field added to the base reaches all three.
typescript
const UserSchema = BaseEntitySchema.extend({
  email: z.email(),
  name: z.string(),
});

const CreateUserSchema = UserSchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true,
});
const UpdateUserSchema = CreateUserSchema.partial();
const UserSummarySchema = UserSchema.pick({ id: true, name: true });
.extend()
is unavailable on a schema that already carries
.refine()
, so extend first and refine last.
Full code: examples/core.md Pattern 3

一个基础 schema,从中派生 read、create 和 update 三种结构——这样添加到基础 schema 的字段会同步进入三者。
typescript
const UserSchema = BaseEntitySchema.extend({
  email: z.email(),
  name: z.string(),
});

const CreateUserSchema = UserSchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true,
});
const UpdateUserSchema = CreateUserSchema.partial();
const UserSummarySchema = UserSchema.pick({ id: true, name: true });
对于已经使用
.refine()
的 schema,
.extend()
不可用,因此应先 extend,最后 refine。
完整代码:examples/core.md 模式 3

Pattern 7: Coercion for string sources

模式 7:针对字符串源的 coercion

URL params and form fields arrive as strings whatever they represent.
z.coerce
converts before checking, so the rules read as the types they are about.
typescript
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(DEFAULT_PAGE),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_LIMIT)
    .default(DEFAULT_LIMIT),
});
z.coerce.boolean()
is
Boolean(value)
, so every non-empty string is
true
— including
"false"
. Use
z.stringbool()
where the string carries the intent.
Full code: examples/transforms.md Pattern 8

URL 参数和表单字段无论表示什么,抵达时都是字符串。
z.coerce
会在检查前进行转换, 因此规则读起来与它们所描述的类型一致。
typescript
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(DEFAULT_PAGE),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_LIMIT)
    .default(DEFAULT_LIMIT),
});
z.coerce.boolean()
就是
Boolean(value)
,因此每个非空字符串都是
true
——包括
"false"
。 当字符串本身承载布尔意图时,应使用
z.stringbool()
。
完整代码:examples/transforms.md 模式 8

Pattern 8: optional, nullable, nullish and default

模式 8:optional、nullable、nullish 与 default

Four distinct claims about an absent value; picking by meaning keeps the type honest.
typescript
const ProfileSchema = z.object({
  name: z.string(), // required
  bio: z.string().optional(), // may be omitted — string | undefined
  avatar: z.url().nullable(), // explicitly empty — string | null
  nickname: z.string().nullish(), // either — string | null | undefined
  theme: z.string().default("light"), // absent becomes "light" — always string
});
.default()
fills a missing key, so the output type stays non-optional while the input type does not — one more reason
z.input
and
z.output
diverge.
</patterns>
<red_flags>
对于缺失值有四种不同的语义声明;按实际含义选择,可让类型保持准确。
typescript
const ProfileSchema = z.object({
  name: z.string(), // required
  bio: z.string().optional(), // may be omitted — string | undefined
  avatar: z.url().nullable(), // explicitly empty — string | null
  nickname: z.string().nullish(), // either — string | null | undefined
  theme: z.string().default("light"), // absent becomes "light" — always string
});
.default()
会填充缺失的键,因此输出类型保持为非可选,而输入类型不是——这也是
z.input
与
z.output
产生差异的又一个原因。
</patterns>
<red_flags>

Red flags

危险信号

Breaks at runtime:
  • parse
    on user input — it throws on the expected case, and a catch block receives the exception after the branch that could have read
    error.issues
    has been skipped. Use
    safeParse
    .
  • An async refinement parsed synchronously — Zod throws rather than awaiting.
    parseAsync
    or
    safeParseAsync
    for any schema containing one.
  • .extend()
    on a schema that already carries
    .refine()
    — the refined schema no longer exposes it. Extend first, refine last.
  • v4:
    .refine(fn, (val) => ({ message }))
    — the function form of the second argument is gone. Use
    superRefine
    where the message depends on the value.
  • v4:
    z.record(valueSchema)
    with one argument — both the key and the value schema are required now.
Surprising behaviour:
  • Unknown keys are stripped rather than rejected.
    z.strictObject()
    rejects them and
    z.looseObject()
    keeps them; the plain object schema quietly drops them, which hides a renamed API field.
  • z.coerce.boolean()
    coerces
    "false"
    to
    true
    , and
    z.coerce.date()
    accepts everything
    new Date()
    does — including strings that parse to a date nobody meant. Where the input is supposed to be ISO,
    z.iso.datetime()
    says so and rejects the rest.
  • z.email()
    rejects the empty string, so an optional field that submits
    ""
    fails. Allow it explicitly or normalise
    ""
    to
    undefined
    before parsing.
  • A
    superRefine
    rule is skipped, not failed, when a key it reads is absent — the object validates.
  • Default
    "Invalid input"
    messages reach the user unchanged wherever a check has no message.
  • v4:
    ctx.path
    is gone from
    superRefine
    —
    ctx.addIssue({ path: [...] })
    still works.
  • v4 replacements:
    .flatten()
    →
    z.flattenError()
    ,
    .format()
    →
    z.treeifyError()
    ,
    .merge()
    →
    .extend()
    .
Worked before/after code for the most common of these is in reference.md.
</red_flags>
运行时出错:
  • 对用户输入使用
    parse
    ——它会在预期的无效场景中抛出异常,而本可读取
    error.issues
    的分支会被跳过, catch 块只能收到异常。请使用
    safeParse
    。
  • 同步解析包含异步 refinement 的 schema——Zod 不会等待,而是直接抛出异常。任何包含异步 refinement 的 schema 都应使用
    parseAsync
    或
    safeParseAsync
    。
  • 对已经使用
    .refine()
    的 schema 调用
    .extend()
    ——refine 后的 schema 不再暴露它。 应先 extend,最后 refine。
  • v4:
    .refine(fn, (val) => ({ message }))
    ——第二个参数的函数形式已移除。 当消息取决于值时,请使用
    superRefine
    。
  • v4:
    z.record(valueSchema)
    只有一个参数——现在必须同时提供 key schema 和 value schema。
意外行为:
  • 未知键会被移除,而不是被拒绝。
    z.strictObject()
    会拒绝未知键,
    z.looseObject()
    会保留未知键; 普通 object schema 会悄悄丢弃它们,从而掩盖 API 字段被重命名的问题。
  • z.coerce.boolean()
    会把
    "false"
    强制转换为
    true
    ,而
    z.coerce.date()
    接受所有
    new Date()
    能接受的内容——包括那些会被解析成非预期日期的字符串。如果输入应当是 ISO 格式,
    z.iso.datetime()
    能明确表达这一要求并拒绝其他内容。
  • z.email()
    会拒绝空字符串,因此提交
    ""
    的可选字段会验证失败。应显式允许它, 或在解析前将
    ""
    规范化为
    undefined
    。
  • 当
    superRefine
    规则读取的某个键不存在时,该规则会被跳过而不是判定失败——对象仍会验证通过。
  • 当某项检查没有提供消息时,默认的
    "Invalid input"
    消息会原样呈现给用户。
  • v4:
    superRefine
    中已移除
    ctx.path
    ——
    ctx.addIssue({ path: [...] })
    仍然可用。
  • v4 替代项:
    .flatten()
    →
    z.flattenError()
    ,
    .format()
    →
    z.treeifyError()
    ,
    .merge()
    →
    .extend()
    。
reference.md 中提供了最常见问题的改写前后代码示例。
</red_flags>