Repository: colinhacks/zod
Pull request #4396 (2025-05-17 23:57:02)

Preserve shape key ordering in result
Modified the JIT pass for object parsing to preserve the shape key order in the result objects. Slight perf hit (~5%). I assumed it would be far more dramatic. I think it's worthwhile to preserve Zod 3 behavior.

coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on base/target branches other than the default branch.
> 
> 
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

<details open="true">
<summary>🧪 Generate Unit Tests</summary>

- [ ] <!-- {"checkboxId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} -->   Create PR with Unit Tests
- [ ] <!-- {"checkboxId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} -->   Commit Unit Tests in branch `v4-key-ordering`
- [ ] <!-- {"checkboxId": "07f1e7d6-8a8e-4e23-9900-8731c2c87f58", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} -->   Post Copyable Unit Tests in Comment

</details>

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=colinhacks/zod&utm_content=4396):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai generate unit tests` to generate unit tests for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->

Patch:
diff --git a/packages/bench/object-moltar.ts b/packages/bench/object-moltar.ts
index 1f377606..146dbabc 100644
--- a/packages/bench/object-moltar.ts
+++ b/packages/bench/object-moltar.ts
@@ -1,4 +1,5 @@
 import * as z4 from "zod/v4";
+import * as z4lib from "./node_modules/zod/dist/esm/v4/index.js";
 import * as z3 from "zod3";
 import { metabench } from "./metabench.js";
 
@@ -16,6 +17,20 @@ const z3Schema = z3.object({
   }).strict()
 }).strict();
 
+const z4LibSchema = z4lib.object({
+  number: z4lib.number(),
+  negNumber: z4lib.number(),
+  maxNumber: z4lib.number(),
+  string: z4lib.string(),
+  longString: z4lib.string(),
+  boolean: z4lib.boolean(),
+  deeplyNested: z4lib.strictObject({
+    foo: z4lib.string(),
+    num: z4lib.number(),
+    bool: z4lib.boolean(),
+  }),
+});
+
 const z4Schema = z4.object({
   number: z4.number(),
   negNumber: z4.number(),
@@ -82,9 +97,12 @@ const bench = metabench("z.object() safeParse", {
   zod3() {
     for (const _ of DATA) z3Schema.parse(_);
   },
-  zod4() {
+  // zod4lib() {
+  //   for (const _ of DATA) z4LibSchema.parse(_);
+  // },
+  zod4(){
     for (const _ of DATA) z4Schema.parse(_);
-  },
+  }
   // zod4strict() {
   //   for (const _ of DATA) z4SchemaStrict.parse(_);
   // },
diff --git a/packages/zod/src/v4/core/doc.ts b/packages/zod/src/v4/core/doc.ts
index 55e7c4fb..9591fe7a 100644
--- a/packages/zod/src/v4/core/doc.ts
+++ b/packages/zod/src/v4/core/doc.ts
@@ -38,6 +38,7 @@ export class Doc {
     const args = this?.args;
     const content = this?.content ?? [``];
     const lines = [...content.map((x) => `  ${x}`)];
+    // console.log(lines.join("\n"));
     return new F(...args, lines.join("\n"));
   }
 }
diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts
index 8bc3f5b3..5df50147 100644
--- a/packages/zod/src/v4/core/schemas.ts
+++ b/packages/zod/src/v4/core/schemas.ts
@@ -1676,8 +1676,6 @@ export interface $ZodObject<
   // @ts-ignore Cast variance
   out Shape extends Readonly<$ZodShape> = Readonly<$ZodShape>,
   out Params extends $ZodObjectConfig = $ZodObjectConfig,
-  // out OutExtra extends Record<string, unknown> = Record<string, unknown>,
-  // out InExtra extends Record<string, unknown> = Record<string, unknown>,
 > extends $ZodType {
   _zod: $ZodObjectInternals<Shape, Params>;
 }
@@ -1727,46 +1725,35 @@ export const $ZodObject: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$con
       return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
     };
 
-    // doc.write(`const shape = inst._zod.def.shape;`);
     doc.write(`const input = payload.value;`);
 
     const ids: any = Object.create(null);
     for (const key of keys) {
       ids[key] = util.randomString(15);
     }
+    // for (const key of keys) {
+    //   if (optionalKeys.has(key)) continue;
+    //   const id = ids[key];
+    //   doc.write(`const ${id} = ${parseStr(key)};`);
+    //   doc.write(`
+    //       if (${id}.issues.length) payload.issues = payload.issues.concat(${id}.issues.map(iss => ({
+    //         ...iss,
+    //         path: iss.path ? [${util.esc(key)}, ...iss.path] : [${util.esc(key)}]
+    //       })));`);
+    // }
+
+    // A: preserve key order {
+    doc.write(`const newResult = {}`);
     for (const key of keys) {
-      if (optionalKeys.has(key)) continue;
-      const id = ids[key];
-      doc.write(`const ${id} = ${parseStr(key)};`);
-      doc.write(`
-          if (${id}.issues.length) payload.issues = payload.issues.concat(${id}.issues.map(iss => ({
-            ...iss,
-            path: iss.path ? [${util.esc(key)}, ...iss.path] : [${util.esc(key)}]
-          })));`);
-    }
-
-    doc.write(`payload.value = {`);
-    doc.indented(() => {
-      for (const key of keys) {
-        if (optionalKeys.has(key)) continue;
+      if (optionalKeys.has(key)) {
         const id = ids[key];
-        doc.write(`  ${util.esc(key)}: ${id}.value,`);
-      }
-    });
-    doc.write(`}`);
-
-    // NEW: always run validation
-    // this lets default values get applied to optionals
-    for (const key of keys) {
-      if (!optionalKeys.has(key)) continue;
-      const id = ids[key];
-      doc.write(`const ${id} = ${parseStr(key)};`);
-      const k = util.esc(key);
-      doc.write(`
+        doc.write(`const ${id} = ${parseStr(key)};`);
+        const k = util.esc(key);
+        doc.write(`
         if (${id}.issues.length) {
           if (input[${k}] === undefined) {
             if (${k} in input) {
-              payload.value[${k}] = undefined;
+              newResult[${k}] = undefined;
             }
           } else {
             payload.issues = payload.issues.concat(
@@ -1777,14 +1764,64 @@ export const $ZodObject: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$con
             );
           }
         } else if (${id}.value === undefined) {
-          if (${k} in input) payload.value[${k}] = undefined;
+          if (${k} in input) newResult[${k}] = undefined;
         } else {
-          if (${k} in input) payload.value[${k}] = ${id}.value;
+          if (${k} in input) newResult[${k}] = ${id}.value;
         }
-          
         `);
+      } else {
+        const id = ids[key];
+        //  const id = ids[key];
+        doc.write(`const ${id} = ${parseStr(key)};`);
+        doc.write(`
+          if (${id}.issues.length) payload.issues = payload.issues.concat(${id}.issues.map(iss => ({
+            ...iss,
+            path: iss.path ? [${util.esc(key)}, ...iss.path] : [${util.esc(key)}]
+          })));`);
+        doc.write(`newResult[${util.esc(key)}] = ${id}.value`);
+      }
     }
 
+    // B: one-shot payload.value
+    // doc.write(`const newResult = {`);
+    // doc.indented(() => {
+    //   for (const key of keys) {
+    //     if (optionalKeys.has(key)) continue;
+    //     const id = ids[key];
+    //     doc.write(`  ${util.esc(key)}: ${id}.value,`);
+    //   }
+    // });
+    // doc.write(`}`);
+
+    // NEW: always run validation
+    // this lets default values get applied to optionals
+    // for (const key of keys) {
+    //   if (!optionalKeys.has(key)) continue;
+    //   const id = ids[key];
+    //   doc.write(`const ${id} = ${parseStr(key)};`);
+    //   const k = util.esc(key);
+    //   doc.write(`
+    //     if (${id}.issues.length) {
+    //       if (input[${k}] === undefined) {
+    //         if (${k} in input) {
+    //           newResult[${k}] = undefined;
+    //         }
+    //       } else {
+    //         payload.issues = payload.issues.concat(
+    //           ${id}.issues.map((iss) => ({
+    //             ...iss,
+    //             path: iss.path ? [${k}, ...iss.path] : [${k}],
+    //           }))
+    //         );
+    //       }
+    //     } else if (${id}.value === undefined) {
+    //       if (${k} in input) newResult[${k}] = undefined;
+    //     } else {
+    //       if (${k} in input) newResult[${k}] = ${id}.value;
+    //     }
+    //     `);
+    // }
+    doc.write(`payload.value = newResult;`);
     doc.write(`return payload;`);
     const fn = doc.compile();
     return (payload: any, ctx: any) => fn(shape, payload, ctx);
diff --git a/play.ts b/play.ts
index 4533e2f6..313a8ef0 100644
--- a/play.ts
+++ b/play.ts
@@ -1,12 +1,9 @@
 import * as z from "zod/v4";
 
-z;
-
-const schema = z.union([z.string(), z.number()]);
-
-z.toJSONSchema(schema, {
-  override(ctx) {
-    console.dir(ctx.zodSchema._zod.def.type, { depth: null });
-    console.dir(ctx.jsonSchema, { depth: null });
-  },
+const schema = z.object({
+  a: z.string().optional(),
+  b: z.string(),
 });
+
+console.dir(schema.safeParse({ a: "asdf", b: "qwer" }), { depth: null });
+console.dir(schema.safeParse({ a: "asdf", b: "qwer" }, { jitless: true }), { depth: null });


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/object.test.ts b/packages/zod/src/v4/classic/tests/object.test.ts
index 0429eade..56d2699b 100644
--- a/packages/zod/src/v4/classic/tests/object.test.ts
+++ b/packages/zod/src/v4/classic/tests/object.test.ts
@@ -474,3 +474,20 @@ test("empty objects", () => {
   type Cin = z.input<typeof C>;
   expectTypeOf<Cin>().toEqualTypeOf<Record<string, never>>();
 });
+
+test("preserve key order", () => {
+  const schema = z.object({
+    a: z.string().optional(),
+    b: z.string(),
+  });
+  const r1 = schema.safeParse({ a: "asdf", b: "qwer" });
+  const r2 = schema.safeParse({ a: "asdf", b: "qwer" }, { jitless: true });
+
+  expect(Object.keys(r1.data!)).toMatchInlineSnapshot(`
+    [
+      "a",
+      "b",
+    ]
+  `);
+  expect(Object.keys(r1.data!)).toEqual(Object.keys(r2.data!));
+});