Repository: colinhacks/zod
Pull request #4121 (2025-04-12 05:33:39)

Add pattern to json schema if present
Addresses https://github.com/colinhacks/zod/issues/4120 by implementing approach 2: adding both `format` and `pattern` regardless of `format`, supposedly [the best practice](https://www.learnjsonschema.com/2020-12/format-annotation/format/). This does increase the schema payload size, which should be largely irrelevant for most purposes of json schema.

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 -->
<!-- tips_start -->

---



<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=4121):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `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 generate unit testing code for this file.`
  -	`@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 generate unit testing code.`
  - `@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.`

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 resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@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 -->
colinhacks: Ah, yeah, this is great. The current behavior is a relic of an older implementation where I checked the format against a set of known JSON Schema formats (datetime, email, etc) and used only `format` in those cases. Then fell back to `pattern` for everything else.

Eventually I decided to always add `format` but forgot to refactor those if statements.

Great catch!

Patch:
diff --git a/packages/core/src/to-json-schema.ts b/packages/core/src/to-json-schema.ts
index b17d50f5..b3d7969e 100644
--- a/packages/core/src/to-json-schema.ts
+++ b/packages/core/src/to-json-schema.ts
@@ -168,7 +168,8 @@ export class JSONSchemaGenerator {
         // custom pattern overrides format
         if (format) {
           json.format = formatMap[format] ?? format;
-        } else if (pattern) {
+        }
+        if (pattern) {
           json.pattern = pattern.source;
         }
         if (contentEncoding) json.contentEncoding = contentEncoding;


Test patch:
diff --git a/packages/zod/tests/json-schema.test.ts b/packages/zod/tests/json-schema.test.ts
index 0269ecb9..b30aba3d 100644
--- a/packages/zod/tests/json-schema.test.ts
+++ b/packages/zod/tests/json-schema.test.ts
@@ -39,54 +39,63 @@ describe("toJSONSchema", () => {
     expect(toJSONSchema(z.email())).toMatchInlineSnapshot(`
       {
         "format": "email",
+        "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.iso.datetime())).toMatchInlineSnapshot(`
       {
         "format": "date-time",
+        "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.iso.date())).toMatchInlineSnapshot(`
       {
         "format": "date",
+        "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.iso.time())).toMatchInlineSnapshot(`
       {
         "format": "time",
+        "pattern": "^([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.iso.duration())).toMatchInlineSnapshot(`
       {
         "format": "duration",
+        "pattern": "^P(?:(\\d+W)|(?!.*W)(?=\\d|T\\d)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+([.,]\\d+)?S)?)?)$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.ipv4())).toMatchInlineSnapshot(`
       {
         "format": "ipv4",
+        "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.ipv6())).toMatchInlineSnapshot(`
       {
         "format": "ipv6",
+        "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.uuid())).toMatchInlineSnapshot(`
       {
         "format": "uuid",
+        "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.guid())).toMatchInlineSnapshot(`
       {
         "format": "uuid",
+        "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$",
         "type": "string",
       }
     `);
@@ -100,12 +109,14 @@ describe("toJSONSchema", () => {
       {
         "contentEncoding": "base64",
         "format": "base64",
+        "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.cuid())).toMatchInlineSnapshot(`
       {
         "format": "cuid",
+        "pattern": "^[cC][^\\s-]{8,}$",
         "type": "string",
       }
     `);
@@ -113,24 +124,28 @@ describe("toJSONSchema", () => {
     expect(toJSONSchema(z.emoji())).toMatchInlineSnapshot(`
       {
         "format": "emoji",
+        "pattern": "^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.nanoid())).toMatchInlineSnapshot(`
       {
         "format": "nanoid",
+        "pattern": "^[a-zA-Z0-9_-]{21}$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.cuid2())).toMatchInlineSnapshot(`
       {
         "format": "cuid2",
+        "pattern": "^[0-9a-z]+$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.ulid())).toMatchInlineSnapshot(`
       {
         "format": "ulid",
+        "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$",
         "type": "string",
       }
     `);
@@ -219,18 +234,21 @@ describe("toJSONSchema", () => {
     expect(toJSONSchema(z.string().email())).toMatchInlineSnapshot(`
       {
         "format": "email",
+        "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.string().uuid())).toMatchInlineSnapshot(`
       {
         "format": "uuid",
+        "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.iso.datetime())).toMatchInlineSnapshot(`
       {
         "format": "date-time",
+        "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$",
         "type": "string",
       }
     `);
@@ -238,18 +256,21 @@ describe("toJSONSchema", () => {
     expect(toJSONSchema(z.iso.date())).toMatchInlineSnapshot(`
       {
         "format": "date",
+        "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.iso.time())).toMatchInlineSnapshot(`
       {
         "format": "time",
+        "pattern": "^([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?$",
         "type": "string",
       }
     `);
     expect(toJSONSchema(z.iso.duration())).toMatchInlineSnapshot(`
       {
         "format": "duration",
+        "pattern": "^P(?:(\\d+W)|(?!.*W)(?=\\d|T\\d)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+([.,]\\d+)?S)?)?)$",
         "type": "string",
       }
     `);
@@ -262,6 +283,7 @@ describe("toJSONSchema", () => {
     expect(toJSONSchema(z.ipv4())).toMatchInlineSnapshot(`
       {
         "format": "ipv4",
+        "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$",
         "type": "string",
       }
     `);
@@ -269,6 +291,7 @@ describe("toJSONSchema", () => {
     expect(toJSONSchema(z.ipv6())).toMatchInlineSnapshot(`
       {
         "format": "ipv6",
+        "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})$",
         "type": "string",
       }
     `);
@@ -277,6 +300,7 @@ describe("toJSONSchema", () => {
       {
         "contentEncoding": "base64",
         "format": "base64",
+        "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$",
         "type": "string",
       }
     `);
@@ -289,6 +313,14 @@ describe("toJSONSchema", () => {
     expect(toJSONSchema(z.guid())).toMatchInlineSnapshot(`
       {
         "format": "uuid",
+        "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$",
+        "type": "string",
+      }
+    `);
+    expect(toJSONSchema(z.string().regex(/asdf/))).toMatchInlineSnapshot(`
+      {
+        "format": "regex",
+        "pattern": "asdf",
         "type": "string",
       }
     `);