创建日期:2026-09-15 | 最近更新:2026-09-15 源码核对自本机 zod 4.5.4 的
node_modules/zod/src/v4/core/*(npm 包里直接附带 TS 源码);运行时结论来自真实探针脚本;v3 对照取自 zod 3.25.76 的类型声明。
Zod 深潜 2:源码剖析——parse 到底做了什么
会用
safeParse只是第一层。这篇带你读 zod v4 的源码,回答几个「用久了必然会问」的问题:校验结果是怎么一路走到底的?为什么 issue 会累积成数组?_zod里装的到底是什么?无 checks 时为什么几乎没开销?
1. 先找准源码位置
zod 的 npm 包里直接带 TS 源码(不用去 GitHub 翻):
node_modules/zod/
├─ src/v4/core/ ← v4 内核(api / schemas / checks / parse / errors / core …)
├─ src/v4/classic/ ← 你平时用的 z.object / z.string(组装内核)
├─ src/v4/mini/ ← zod/mini(极简版)
├─ src/mini/ src/v3/ ← 兼容入口
记住这个分层:classic 是「用户 API」,core 是「引擎」。你 import 的 z.string() 是 classic 组装的,真正的校验逻辑在 core。
2. 一个 schema 里装了什么(真实运行时探针)
const s = z.string().min(3).max(10);
Object.keys(s) // → def, type, format, minLength, maxLength
Object.keys(s._zod) // → def, constr, traits, bag, version, run, parse, processJSONSchema, parent
JSON.stringify(s._zod.def) // → {"type":"string","checks":[{},{}]}
几个关键点:
| 成员 | 含义 |
|---|---|
_zod.def | 这张 schema 的定义(type + checks 数组)——.min(3) 只是往 checks 里塞了一个 check |
_zod.parse | 只做「类型解析」,不跑校验 |
_zod.run | 跑解析 + 所有 checks(源码注释原文:"Parses input and runs all checks (refinements).") |
_zod.traits | 这张 schema 实现了哪些「特质」(如 min_length)的标识集合 |
_zod.bag | 聚合元数据(minimum / maximum / patterns / format…),给 toJSONSchema 这类工具用 |
_zod.deferred | 延迟初始化钩子(见 §7 的 memoizer) |
注意
checks里JSON.stringify出来是{}—— 因为里面装的是函数。校验规则不是数据,是函数。
3. 主流程:一次 parse 的完整链路
源码里 _parse 的核心只有几行(src/v4/core/parse.ts,真实节选):
export const _parse: (_Err: $ZodErrorClass) => $Parse = (_Err) => {
const fn: $Parse = (schema, value, _ctx, _params) => {
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
const result = schema._zod.run({ value, issues: [] }, ctx); // ★ 一切从这里开始
if (result instanceof Promise) {
throw new core.$ZodAsyncError(); // 同步 parse 遇到异步 schema
}
if (result.issues.length) {
const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
util.captureStackTrace(e, _params?.callee ?? fn);
throw e; // ★ 错误在这里才被“组装成异常”
}
return result.value as core.output<typeof schema>;
};
return fn;
};
export const parse: $Parse = /* @__PURE__*/ _parse(errors.$ZodRealError);
三个立刻能用上的结论:
run是唯一入口:payload 形如{ value, issues: [] }——issues是先建好、一路往里塞的数组;- 内部不 throw,只 push:校验失败是把 issue 塞进
payload.issues,顶层才构造并抛出错误。所以嵌套校验能一次性收集多条 issue,而不是第一个错就中断——这就是message: [...]是数组的根源; - 同步
parse不接受异步 schema:遇到 Promise 直接抛$ZodAsyncError—— 这就是「用了异步refine必须用parseAsync」的机制原因。
4. 单个类型是怎么实现的($ZodString)
src/v4/core/schemas.ts 里字符串类型的实现(真实节选 + 我加的注释):
export const $ZodString: core.$constructor<$ZodString> = core.$constructor("$ZodString", (inst, def) => {
$ZodType.init(inst, def); // 通用初始化(含 run/checks 装配)
inst._zod.pattern = [...(inst?._zod.bag?.patterns ?? [])].pop() ?? regexes.string(inst._zod.bag);
inst._zod.parse = (payload, _) => {
if (def.coerce)
try { payload.value = String(payload.value); } catch (_) {} // ← coerce 是内联在 parse 里的
if (typeof payload.value === "string") return payload; // 类型对 → 原样返回
payload.issues.push({ expected: "string", code: "invalid_type", input: payload.value, inst });
return payload; // 类型错 → push issue,仍返回 payload
};
});
三个细节:
coerce不是额外插件,就在parse里内联一句String(...);_zod.bag参与了pattern的推导(多个 pattern 取最后一个)——bag是「给外部工具看的元数据」;- 错误是 push 不是 throw(再次印证 §3 第 2 点)。
5. checks:.min(3) 到底加了什么
.min(3) 往 def.checks 里塞一个 check 对象(src/v4/core/checks.ts 里有 $ZodCheckMinLength)。而「无 checks 时零开销」这件事,源码写得很直白(schemas.ts 真实节选):
if (checks.length === 0) {
// …
inst._zod.run = inst._zod.parse; // ★ 没有 checks:run 直接就是 parse
}
有 checks 时,run 才变成「先 parse、再逐个跑 check」的包装(源码第 295 行起)。这就是「一堆没加约束的 z.string() 几乎不花钱」的源码依据。
6. 错误对象:issue 长什么样(真实探针)
z.string().min(3).safeParse('ab').error.issues[0]
{ "origin": "string", "code": "too_small", "minimum": 3, "inclusive": true,
"path": [], "message": "Too small: expected string to have >=3 characters" }
code+minimum/inclusive是机器可读参数(v4 强化了这块,方便你自己渲染国际化文案);origin标记「哪一类 schema 报的」;path是位置(嵌套对象里会是['user','age']);message只是默认文案——你可以完全绕过它,用code自己产出 UI 文案。
7. v4 里还有几个「读者彩蛋」
| 文件 | 作用 |
|---|---|
standard-schema.ts | Standard Schema 规范兼容层(让 zod schema 能被任何支持该规范的工具消费) |
memoizer.ts | deferred 延迟初始化:schema 构造时先登记,之后再打补丁(源码注释解释了「无 checks 时 run 被复制为 parse」的修补逻辑) |
json-schema-generator.ts / to-json-schema.ts | schema ↔ JSON Schema 转换(MCP 那篇 用的就是这个能力) |
compile.ts | 把 schema 编译成更快的解析器(zod/compile 入口) |
registries.ts / versions.ts | 全局注册表(如全局错误文案)与版本标记 |
顺带一个性能彩蛋:schemas.ts 里关于 object 解析的注释提到,他们用闭包捕获 shape 而不是把它当参数传,好让 V8 的 TurboFan 针对这个具体 shape 做特化——注释里给的数字是「当参数传会慢 13%,即使去掉转发帧也一样」。真实的源码级性能工程。
8. v3 → v4:架构对照(都验证过)
| zod v3(3.25.76) | zod v4(4.5.4) | |
|---|---|---|
| 校验入口 | abstract _parse(input: ParseInput): ParseReturnType<Output> | _zod.run(payload, ctx) / _zod.parse(payload, ctx) |
| 状态表示 | ParseStatus(valid / dirty / aborted)+ ParseContext | payload { value, issues[] } + ctx { async } |
| 错误传递 | 内部 status 流转,顶层处理 | issues 累积在 payload,顶层 finalizeIssue 后构造异常 |
| 元数据 | _def | _zod.def + _zod.bag / _zod.traits |
| 内部命名空间 | 扁平(_def / _parse) | 统一收进 _zod(def/parse/run/bag/traits/constr/deferred) |
v4 的这次重写,本质是把「校验过程中的状态」从各种返回值里,收敛成一个明确的 payload 对象——这既解释了「为什么 v4 类型更快」(类型系统也跟着简化了,见 上一篇 的实测),也解释了「为什么 v4 的错误信息更结构化」。
9. 那么,我该不该碰这些内部 API?
不该。 _zod 是内部实现(源码注释里明确标了 Internal API)。但要理解它,因为它能解释你日常遇到的四件事:
- 为什么错误是数组(issues 一路累积);
- 为什么异步校验必须
parseAsync(同步parse遇到 Promise 会抛$ZodAsyncError); - 为什么「不加约束的 schema」几乎不花钱(无 checks 时
run === parse); - 为什么
could.某些错误文案能被你换掉(message只是默认值,code才是事实)。
关联
- 上一篇:类型推导剖析
- 入门:Zod 入门 | 生态:MCP 与 Zod
- 源码路径:
node_modules/zod/src/v4/core/{parse,schemas,checks,core,errors}.ts(本文引用均出自此处)
自测
_zod.run和_zod.parse差在哪?没有 checks 时哪个更快、为什么?- 校验失败时,issue 是在哪一步被「变成异常」的?这让错误有什么特性?
- 为什么同步
parse不能用于异步refine? coerce在源码里是怎么实现的?是插件吗?- v3 的
_parse+ParseStatus与 v4 的_zod.run+payload.issues有什么本质差别?