Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/docs/content/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:[email protected]"); // ❌
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.
Expand Down
1 change: 1 addition & 0 deletions packages/zod/src/v4/classic/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions packages/zod/src/v4/classic/tests/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:[email protected]/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("[email protected]")).toThrow();
expect(() => url.parse("http://localhost")).toThrow();
});

test("url error overrides", () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/zod/src/v4/core/regexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]$/;
Expand Down
50 changes: 47 additions & 3 deletions packages/zod/src/v4/core/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand Down
62 changes: 60 additions & 2 deletions packages/zod/src/v4/mini/tests/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:[email protected]")).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", () => {
Expand Down
18 changes: 13 additions & 5 deletions play.ts
Original file line number Diff line number Diff line change
@@ -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"); // ❌
Loading