Repository: colinhacks/zod
Pull request #4367 (2025-05-15 05:36:22)

Improve `z.url()`: support `hostname` and `protocol` regexes
This drops the more restrictive hostname check that existed in earlier versions of Zod 4. Instead, this adds support for optional `protocol` and `hostname` regexes. 

By default Zod still validates the `hostname` against this much more forgiving regex:

```ts
export const hostname: RegExp = /^([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/;
```

---

To validate any WHATWG-compatible URL.

```ts
const schema = z.url();

schema.parse("https://example.com"); // ✅
schema.parse("http://localhost"); // ✅

schema.parse("mailto:noreply@zod.dev"); // ❌
schema.parse("sup"); // ❌
```

To validate the hostname against a specific regex:

```ts
const schema = z.url({ hostname: /^example.com$/ });

schema.parse("https://example.com"); // ✅
schema.parse("https://zombo.com"); // ❌
```

To validate the protocol against a specific regex, use the `protocol` param.

```ts
const schema = z.url({ protocol: /^https$/ });

schema.parse("https://example.com"); // ✅
schema.parse("http://example.com"); // ❌
```


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

---

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

- 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 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/docs/content/api.mdx b/packages/docs/content/api.mdx
index 10f0e484..578df4e5 100644
--- a/packages/docs/content/api.mdx
+++ b/packages/docs/content/api.mdx
@@ -292,6 +292,41 @@ The RFC 4122 UUID spec requires the first two bits of byte 8 to be `10`. Other U
 z.guid();
 ```
 
+### URLs
+
+To validate any WHATWG-compatible URL.
+
+```ts
+const schema = z.url();
+
+schema.parse("https://example.com"); // ✅
+schema.parse("http://localhost"); // ✅
+
+schema.parse("mailto:noreply@zod.dev"); // ❌
+schema.parse("sup"); // ❌
+```
+
+> Internally this uses the `new URL()` constructor to perform validation. This may behave differently across platforms and runtimes but is generally the most rigorous way to validate URIs/URLs. 
+
+To validate the hostname against a specific regex:
+
+```ts
+const schema = z.url({ hostname: /^example.com$/ });
+
+schema.parse("https://example.com"); // ✅
+schema.parse("https://zombo.com"); // ❌
+```
+
+To validate the protocol against a specific regex, use the `protocol` param.
+
+```ts
+const schema = z.url({ protocol: /^https$/ });
+
+schema.parse("https://example.com"); // ✅
+schema.parse("http://example.com"); // ❌
+```
+
+
 ### ISO datetimes
 
 As you may have noticed, Zod string includes a few date/time related validations. These validations are regular expression based, so they are not as strict as a full date/time library. However, they are very convenient for validating user input.
diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts
index 13b088cf..c781a713 100644
--- a/packages/zod/src/v4/classic/schemas.ts
+++ b/packages/zod/src/v4/classic/schemas.ts
@@ -230,6 +230,7 @@ export interface _ZodString<Input = unknown> extends ZodType {
   toUpperCase(): this;
 }
 
+/** @internal */
 export const _ZodString: core.$constructor<_ZodString> = /*@__PURE__*/ core.$constructor("_ZodString", (inst, def) => {
   core.$ZodString.init(inst, def);
   ZodType.init(inst, def);
diff --git a/packages/zod/src/v4/core/regexes.ts b/packages/zod/src/v4/core/regexes.ts
index f1638225..e4a25296 100644
--- a/packages/zod/src/v4/core/regexes.ts
+++ b/packages/zod/src/v4/core/regexes.ts
@@ -71,8 +71,9 @@ export const base64: RegExp = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-
 export const base64url: RegExp = /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/;
 
 // based on https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
-export const hostname: RegExp =
-  /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
+// export const hostname: RegExp =
+//   /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
+export const hostname: RegExp = /^([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/;
 
 // https://blog.stevenlevithan.com/archives/validate-phone-number#r4-3 (regex sans spaces)
 export const e164: RegExp = /^\+(?:[0-9]){6,14}[0-9]$/;
diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts
index cd8aa3c9..ad14e62a 100644
--- a/packages/zod/src/v4/core/schemas.ts
+++ b/packages/zod/src/v4/core/schemas.ts
@@ -414,8 +414,13 @@ export const $ZodEmail: core.$constructor<$ZodEmail> = /*@__PURE__*/ core.$const
 
 //////////////////////////////   ZodURL   //////////////////////////////
 
-export interface $ZodURLDef extends $ZodStringFormatDef<"url"> {}
-export interface $ZodURLInternals extends $ZodStringFormatInternals<"url"> {}
+export interface $ZodURLDef extends $ZodStringFormatDef<"url"> {
+  hostname?: RegExp | undefined;
+  protocol?: RegExp | undefined;
+}
+export interface $ZodURLInternals extends $ZodStringFormatInternals<"url"> {
+  def: $ZodURLDef;
+}
 
 export interface $ZodURL extends $ZodType {
   _zod: $ZodURLInternals;
@@ -426,8 +431,47 @@ export const $ZodURL: core.$constructor<$ZodURL> = /*@__PURE__*/ core.$construct
   inst._zod.check = (payload) => {
     try {
       const url = new URL(payload.value);
+
       regexes.hostname.lastIndex = 0;
-      if (!regexes.hostname.test(url.hostname)) throw new Error();
+      if (!regexes.hostname.test(url.hostname)) {
+        payload.issues.push({
+          code: "invalid_format",
+          format: "url",
+          note: "Invalid hostname",
+          pattern: regexes.hostname.source,
+          input: payload.value,
+          inst,
+        });
+      }
+
+      if (def.hostname) {
+        def.hostname.lastIndex = 0;
+        if (!def.hostname.test(url.hostname)) {
+          payload.issues.push({
+            code: "invalid_format",
+            format: "url",
+            note: "Invalid hostname",
+            pattern: def.hostname.source,
+            input: payload.value,
+            inst,
+          });
+        }
+      }
+
+      if (def.protocol) {
+        def.protocol.lastIndex = 0;
+        if (!def.protocol.test(url.protocol.endsWith(":") ? url.protocol.slice(0, -1) : url.protocol)) {
+          payload.issues.push({
+            code: "invalid_format",
+            format: "url",
+            note: "Invalid protocol",
+            pattern: def.protocol.source,
+            input: payload.value,
+            inst,
+          });
+        }
+      }
+
       return;
     } catch (_) {
       payload.issues.push({
diff --git a/play.ts b/play.ts
index 77881a64..cbecf4cd 100644
--- a/play.ts
+++ b/play.ts
@@ -1,7 +1,15 @@
 import { z } from "zod/v4";
-const schema_02 = z.enum({
-  A: 1,
-  B: "A",
-});
 
-schema_02.parse("A");
+// const schema = z.url();
+
+// schema.parse("https://example.com"); // ✅
+// schema.parse("http://localhost"); // ✅
+// schema.parse("sup");
+
+// const schema = z.url({ hostname: /^example.com$/ });
+// schema.parse("https://example.com"); // ✅
+// schema.parse("https://zombo.com"); // ❌
+
+// const schema = z.url({ protocol: /^https$/ });
+// schema.parse("https://example.com"); // ✅
+// schema.parse("httpss://example.com"); // ❌


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/string.test.ts b/packages/zod/src/v4/classic/tests/string.test.ts
index a976a424..1facb0d6 100644
--- a/packages/zod/src/v4/classic/tests/string.test.ts
+++ b/packages/zod/src/v4/classic/tests/string.test.ts
@@ -282,14 +282,17 @@ test("url validations", () => {
   url.parse("http://google.com");
   url.parse("https://google.com/asdf?asdf=ljk3lk4&asdf=234#asdf");
   url.parse("https://anonymous:flabada@developer.mozilla.org/en-US/docs/Web/API/URL/password");
+  url.parse("https://localhost");
+  url.parse("https://my.local");
+  url.parse("http://aslkfjdalsdfkjaf");
+  url.parse("http://localhost");
+
   expect(() => url.parse("asdf")).toThrow();
   expect(() => url.parse("http:.......///broken.com")).toThrow();
   expect(() => url.parse("c:")).toThrow();
   expect(() => url.parse("WWW:WWW.COM")).toThrow();
   expect(() => url.parse("https:/")).toThrow();
-  expect(() => url.parse("https://asdf")).toThrow();
   expect(() => url.parse("asdfj@lkjsdf.com")).toThrow();
-  expect(() => url.parse("http://localhost")).toThrow();
 });
 
 test("url error overrides", () => {
diff --git a/packages/zod/src/v4/mini/tests/string.test.ts b/packages/zod/src/v4/mini/tests/string.test.ts
index d12f13b7..2bbf77c8 100644
--- a/packages/zod/src/v4/mini/tests/string.test.ts
+++ b/packages/zod/src/v4/mini/tests/string.test.ts
@@ -88,8 +88,66 @@ test("z.email", () => {
 
 test("z.url", () => {
   const a = z.url();
-  expect(z.parse(a, "http://example.com")).toEqual("http://example.com");
-  expect(() => z.parse(a, "asdf")).toThrow();
+  // valid URLs
+  expect(a.parse("http://example.com")).toEqual("http://example.com");
+  expect(a.parse("https://example.com")).toEqual("https://example.com");
+  expect(a.parse("ftp://example.com")).toEqual("ftp://example.com");
+  expect(a.parse("http://sub.example.com")).toEqual("http://sub.example.com");
+  expect(a.parse("https://example.com/path?query=123#fragment")).toEqual("https://example.com/path?query=123#fragment");
+  expect(a.parse("http://localhost")).toEqual("http://localhost");
+  expect(a.parse("https://localhost")).toEqual("https://localhost");
+  expect(a.parse("http://localhost:3000")).toEqual("http://localhost:3000");
+  expect(a.parse("https://localhost:3000")).toEqual("https://localhost:3000");
+
+  // invalid URLs
+  expect(() => a.parse("not-a-url")).toThrow();
+  // expect(() => a.parse("http:/example.com")).toThrow();
+  expect(() => a.parse("://example.com")).toThrow();
+  expect(() => a.parse("http://")).toThrow();
+  expect(() => a.parse("example.com")).toThrow();
+
+  // wrong type
+  expect(() => a.parse(123)).toThrow();
+  expect(() => a.parse(null)).toThrow();
+  expect(() => a.parse(undefined)).toThrow();
+});
+
+test("z.url with optional hostname regex", () => {
+  const a = z.url({ hostname: /example\.com$/ });
+  expect(a.parse("http://example.com")).toEqual("http://example.com");
+  expect(a.parse("https://sub.example.com")).toEqual("https://sub.example.com");
+  expect(() => a.parse("http://examples.com")).toThrow();
+  expect(() => a.parse("http://example.org")).toThrow();
+  expect(() => a.parse("asdf")).toThrow();
+});
+
+test("z.url with optional protocol regex", () => {
+  const a = z.url({ protocol: /^https?$/ });
+  expect(a.parse("http://example.com")).toEqual("http://example.com");
+  expect(a.parse("https://example.com")).toEqual("https://example.com");
+  expect(() => a.parse("ftp://example.com")).toThrow();
+  expect(() => a.parse("mailto:example@example.com")).toThrow();
+  expect(() => a.parse("asdf")).toThrow();
+});
+
+test("z.url with both hostname and protocol regexes", () => {
+  const a = z.url({ hostname: /example\.com$/, protocol: /^https$/ });
+  expect(a.parse("https://example.com")).toEqual("https://example.com");
+  expect(a.parse("https://sub.example.com")).toEqual("https://sub.example.com");
+  expect(() => a.parse("http://example.com")).toThrow();
+  expect(() => a.parse("https://example.org")).toThrow();
+  expect(() => a.parse("ftp://example.com")).toThrow();
+  expect(() => a.parse("asdf")).toThrow();
+});
+
+test("z.url with invalid regex patterns", () => {
+  const a = z.url({ hostname: /a+$/, protocol: /^ftp$/ });
+  a.parse("ftp://a");
+  a.parse("ftp://aaaaaaaa");
+  expect(() => a.parse("http://aaa")).toThrow();
+  expect(() => a.parse("https://example.com")).toThrow();
+  expect(() => a.parse("ftp://asdfasdf")).toThrow();
+  expect(() => a.parse("ftp://invalid")).toThrow();
 });
 
 test("z.emoji", () => {