fix: respect abort: true in .refine() for checks with when function#5681
Conversation
…tion The `abort: true` option in `.refine()` was not stopping subsequent checks like `.min()` because those checks have a `when` function that bypassed the abort check entirely. This fix adds `explicitlyAborted()` to distinguish between: - Explicit abort (`continue: false`) from `abort: true` in `.refine()` - Implicit abort (`continue: undefined`) from type errors Checks with a `when` function now respect explicit aborts while still allowing implicit aborts to pass through, preserving existing behavior where type errors don't prevent other checks from running. Closes #5680
|
Related Documentation No published documentation to review for changes on this repository. |
|
cc @pullfrog review |
There was a problem hiding this comment.
Low urgency. The core logic fix is correct and addresses the reported issue. The play.ts file contains unrelated debugging code that should be reverted to its original state before merging.
| let x: "a" | "b" | false = false as "a" | "b" | false; | ||
|
|
||
| const type1 = z.literal(["a", "b", "c"]); | ||
| const type2 = z.literal([1, 2, 3]); | ||
| for (const item of [1, 2, 3]) { | ||
| if (x === "a") { | ||
| // branch 1: doesn't always continue | ||
| console.log("a"); | ||
| } else if (x) { | ||
| // branch 2: continue narrows x to false after this | ||
| continue; | ||
| } | ||
|
|
||
| const record = z.partialRecord(z.union([type1, type2]), z.string()); | ||
| // TS thinks x is false | "a" here (eliminated "b" via the continue) | ||
| // But actually x could be "a" | "b" | false because: | ||
| // - branch 1 falls through without continue when x === "a" | ||
| // - branch 2 continues when x === "b" | ||
| // - x === false falls through | ||
| // After mutation below, x could be any value on next iteration | ||
|
|
||
| console.log(record.parse({ a: "1", 2: "4" })); | ||
| // This comparison errors: TS says x can't be "b" | ||
| if (x !== "b") { | ||
| x = "b"; // but we can assign "b" here | ||
| } | ||
| } |
There was a problem hiding this comment.
This file was modified with unrelated TypeScript flow control analysis code. It should be reverted to its original content:
import { z } from "zod";
const type1 = z.literal(["a", "b", "c"]);
const type2 = z.literal([1, 2, 3]);
const record = z.partialRecord(z.union([type1, type2]), z.string());
console.log(record.parse({ a: "1", 2: "4" }));| // Returns "explicit" for hard aborts (continue: false, from abort:true), | ||
| // "implicit" for soft aborts (continue: undefined, from type errors), |
There was a problem hiding this comment.
Minor documentation nit: The comment says continue: undefined is "from type errors", but looking at the code, type errors actually set continue: false (e.g., in checks.ts line 300). The continue: undefined case is from built-in checks like .min(), .max(), etc. Consider updating to:
// Returns "explicit" for hard aborts (continue: false, from abort:true or type errors),
// "implicit" for soft aborts (continue: undefined, from built-in checks),154665c to
32e8333
Compare
|
Landed in Zod 4.4 |
Summary
Fixes the issue where
abort: truein.refine()was not stopping subsequent checks like.min().Problem
Root Cause
Built-in checks like
.min()have awhenfunction (used to skip checks onnull/undefinedvalues). When a check had awhenfunction, the abort check was bypassed entirely, meaningabort: trueonly stopped subsequent checks without awhenfunction.Solution
Added
explicitlyAborted()to distinguish between:continue: false) — fromabort: truein.refine()continue: undefined) — from type errors likeinvalid_typeChecks with a
whenfunction now respect explicit aborts while still allowing implicit aborts to pass through. This preserves the existing behavior where type errors don't prevent other checks from running (e.g., array element type errors don't preventmin(2)from running).Test Results
All 3573 tests pass.
Closes #5680