Zod 4.6.7 异步 union 共享递归 schema,错误类型仍校验通过
同一份错误输入,两个分支单独校验都失败,异步 union 却返回成功。本文限定 Zod 4.6.7 共享递归 schema 的组合,给出独立分支校验及失败处理方法。
同一个输入,单独校验两个 schema 都失败,放进 z.union() 再调用 safeParseAsync() 却返回了 success: true。如果两个分支共享递归对象 schema,其中又有异步 refinement,要把这条组合单独检查,不能直接相信这次联合校验已完成所有字段检查。
下面限定 Zod 4.6.7 中的具体形态:共享的 Node 含 z.lazy() 递归字段,并且一个字符串字段使用异步检查。数字 id 被传入字符串,本应失败。
import * as z from 'zod';
const Node = z.object({
id: z.number(),
slow: z.string().refine(async () => true),
kids: z.array(z.lazy(() => Node)),
});
const U = z.union([
z.object({ child: Node }),
z.object({ child: Node, extra: z.number().optional() }),
]);
const input = {
child: { id: 'not a number', slow: 'x', kids: [] },
};
对这类输入,应比较联合入口与 U.options 中各分支的独立异步校验。不能把一个分支单独通过的正常数据当成反例,也不能将这项结果扩大为所有异步 union 都会接受错误类型。
共享解析上下文遇到了未完成的记录
4.6.7 的 union 会用同一个解析上下文发起各分支。递归对象的 memoizer 以 schema 和输入对象为键保存解析记录;命中一条尚未完成的记录时,会把它视作递归回边,复用输出并跳过这一层检查。
这组输入里,同一个 child 对象先后进入两个兄弟分支。异步检查使第一份记录尚未完成,后一个分支可能按回边处理,却没有取得前一分支最终记录的字段错误。源码中这条路径与症状相符,但不能据此认定所有重复对象引用、所有历史版本或同步检查都受影响。
临时把这些分支作为独立调用依次校验
如果现有接口必须保留这组候选,可以在应用的这个入口依次调用各分支,而不是继续走有问题的 union 组合。下面接在前面的 U 定义后,使用每次调用默认新建的解析上下文:
async function checkOptionsInOrder(value) {
const errors = [];
for (const option of U.options) {
const result = await option.safeParseAsync(value);
if (result.success) return { success: true, data: result.data };
errors.push(result.error);
}
return { success: false, errors };
}
这是应用自己的返回协议,失败时是 errors 数组,不是 Zod safeParse 的单个 error,不能直接替换后让原错误处理继续读 result.error。全部分支失败时,应走原有输入拒绝路径,不能返回未校验的原值。
顺序执行还会改变异步检查、转换的调用时机。原本带写入或外部副作用的 refinement 尤其需要检查;不要为修一次校验错误重复执行扣费、提交等业务动作。这里也只绕开最外层这些分支的共享调用,如果分支内部仍有同类异步递归 union,要继续检查那一层。
不要用 Promise.all(U.options.map(...)) 代替上述顺序,再假定两者行为完全一致;也不要把真实异步校验改成一个永远返回 true 的同步函数。无须全局关闭递归支持,或修改 Zod 内部 memo 状态。
在受控环境保留错误 id、合法 id、两个分支各自失败以及嵌套递归节点四类输入。错误数据必须被拒绝,成功结果应来自选中的 schema 输出,包含原转换结果。在 4.6.7 上应保留这项检查;采用后续修正时,应在实际安装包上重查这组输入,不能只凭版本号变化删除入口保护。


