Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add E.164 format validation for strings #3476

Merged
merged 5 commits into from
May 16, 2024
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ z.string().date(); // ISO date format (YYYY-MM-DD)
z.string().time(); // ISO time format (HH:mm:ss[.SSSSSS])
z.string().duration(); // ISO 8601 duration
z.string().base64();
z.string().e164(); // E.164 number format
```

> Check out [validator.js](https://github.com/validatorjs/validator.js) for a bunch of other useful string validation functions that can be used in conjunction with [Refinements](#refine).
Expand Down Expand Up @@ -825,6 +826,20 @@ if (!env.success) {

This is recommended over using `z.string().transform(s => JSON.parse(s))`, since that will not catch parse errors, even when using `.safeParse`.

### E.164 telephone numbers

The z.string().e164() method can be used to validate international telephone numbers.

```ts
const e164Number = z.string().e164();

e164Number.parse("+1555555"); // pass
e164Number.parse("+155555555555555"); // pass

e164Number.parse("555555555"); // fail
e164Number.parse("+1 1555555"); // fail
```

## Numbers

You can customize certain error messages when creating a number schema.
Expand Down
15 changes: 15 additions & 0 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ z.string().date(); // ISO date format (YYYY-MM-DD)
z.string().time(); // ISO time format (HH:mm:ss[.SSSSSS])
z.string().duration(); // ISO 8601 duration
z.string().base64();
z.string().e164(); // E.164 number format
```

> Check out [validator.js](https://github.com/validatorjs/validator.js) for a bunch of other useful string validation functions that can be used in conjunction with [Refinements](#refine).
Expand Down Expand Up @@ -825,6 +826,20 @@ if (!env.success) {

This is recommended over using `z.string().transform(s => JSON.parse(s))`, since that will not catch parse errors, even when using `.safeParse`.

### E.164 telephone numbers

The z.string().e164() method can be used to validate international telephone numbers.

```ts
const e164Number = z.string().e164();

e164Number.parse("+1555555"); // pass
e164Number.parse("+155555555555555"); // pass

e164Number.parse("555555555"); // fail
e164Number.parse("+1 1555555"); // fail
```

## Numbers

You can customize certain error messages when creating a number schema.
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export type StringValidation =
| "duration"
| "ip"
| "base64"
| "e164"
| { includes: string; position?: number }
| { startsWith: string }
| { endsWith: string };
Expand Down
38 changes: 38 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,3 +906,41 @@ test("IP validation", () => {
invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false)
).toBe(true);
});

test("E.164 validation", () => {
const e164Number = z.string().e164();
expect(e164Number.safeParse("+1555555").success).toBe(true);

const validE164Numbers = [
"+1555555", // min-length (7 digits + '+')
"+15555555",
"+155555555",
"+1555555555",
"+15555555555",
"+155555555555",
"+1555555555555",
"+15555555555555",
"+155555555555555",
"+105555555555555",
"+100555555555555", // max-length (15 digits + '+')
];

const invalidE164Numbers = [
"", // empty
"+", // only plus sign
"-", // wrong sign
" 555555555", // starts with space
"555555555", // missing plus sign
"+1 555 555 555", // space after plus sign
"+1555 555 555", // space between numbers
"+1555+555", // multiple plus signs
"+1555555555555555", // too long
"+115abc55", // non numeric characters in number part
"+1555555 ", // space after number
];

expect(validE164Numbers.every((number) => e164Number.safeParse(number).success)).toBe(true);
expect(
invalidE164Numbers.every((number) => e164Number.safeParse(number).success === false)
).toBe(true);
});
26 changes: 23 additions & 3 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,8 @@ export type ZodStringCheck =
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string }
| { kind: "json"; message?: string };
| { kind: "json"; message?: string }
| { kind: "e164"; message?: string };

export interface ZodStringDef extends ZodTypeDef {
checks: ZodStringCheck[];
Expand Down Expand Up @@ -610,18 +611,20 @@ let emojiRegex: RegExp;
// faster, simpler, safer
const ipv4Regex =
/^(?:(?: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])$/;

const ipv6Regex =
/^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/;

// https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript
const base64Regex =
/^([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

const hostnameRegex =
/^(([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])$/;

// https://blog.stevenlevithan.com/archives/validate-phone-number#r4-3 (regex sans spaces)
const e164Regex = /^\+(?:[0-9]){6,14}[0-9]$/;

// simple
// const dateRegexSource = `\\d{4}-\\d{2}-\\d{2}`;
// no leap year validation
Expand Down Expand Up @@ -1035,6 +1038,16 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
message: check.message,
});
}
} else if (check.kind === "e164") {
if (!e164Regex.test(input)) {
issues = issues || [];
issues.push({
input,
validation: "e164",
code: ZodIssueCode.invalid_string,
message: check.message,
});
}
} else {
util.assertNever(check);
}
Expand Down Expand Up @@ -1122,6 +1135,10 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) });
}

e164(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "e164", ...errorUtil.errToObj(message) });
}

datetime(
options?:
| string
Expand Down Expand Up @@ -1353,6 +1370,9 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
get isBase64() {
return !!this._def.checks.find((ch) => ch.kind === "base64");
}
get isE164() {
return !!this._def.checks.find((ch) => ch.kind === "e164");
}

get minLength() {
let min: number | null = null;
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export type StringValidation =
| "duration"
| "ip"
| "base64"
| "e164"
| { includes: string; position?: number }
| { startsWith: string }
| { endsWith: string };
Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,3 +905,41 @@ test("IP validation", () => {
invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false)
).toBe(true);
});

test("E.164 validation", () => {
const e164Number = z.string().e164();
expect(e164Number.safeParse("+1555555").success).toBe(true);

const validE164Numbers = [
"+1555555", // min-length (7 digits + '+')
"+15555555",
"+155555555",
"+1555555555",
"+15555555555",
"+155555555555",
"+1555555555555",
"+15555555555555",
"+155555555555555",
"+105555555555555",
"+100555555555555", // max-length (15 digits + '+')
];

const invalidE164Numbers = [
"", // empty
"+", // only plus sign
"-", // wrong sign
" 555555555", // starts with space
"555555555", // missing plus sign
"+1 555 555 555", // space after plus sign
"+1555 555 555", // space between numbers
"+1555+555", // multiple plus signs
"+1555555555555555", // too long
"+115abc55", // non numeric characters in number part
"+1555555 ", // space after number
];

expect(validE164Numbers.every((number) => e164Number.safeParse(number).success)).toBe(true);
expect(
invalidE164Numbers.every((number) => e164Number.safeParse(number).success === false)
).toBe(true);
});
24 changes: 22 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,7 @@ export type ZodStringCheck =
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string }
| { kind: "json"; message?: string };
| { kind: "e164"; message?: string };

export interface ZodStringDef extends ZodTypeDef {
checks: ZodStringCheck[];
Expand Down Expand Up @@ -610,18 +611,20 @@ let emojiRegex: RegExp;
// faster, simpler, safer
const ipv4Regex =
/^(?:(?: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])$/;

const ipv6Regex =
/^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/;

// https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript
const base64Regex =
/^([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

const hostnameRegex =
/^(([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])$/;

// https://blog.stevenlevithan.com/archives/validate-phone-number#r4-3 (regex sans spaces)
const e164Regex = /^\+(?:[0-9]){6,14}[0-9]$/;

// simple
// const dateRegexSource = `\\d{4}-\\d{2}-\\d{2}`;
// no leap year validation
Expand Down Expand Up @@ -1035,6 +1038,16 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
message: check.message,
});
}
} else if (check.kind === "e164") {
if (!e164Regex.test(input)) {
issues = issues || [];
issues.push({
input,
validation: "e164",
code: ZodIssueCode.invalid_string,
message: check.message,
});
}
} else {
util.assertNever(check);
}
Expand Down Expand Up @@ -1122,6 +1135,10 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) });
}

e164(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "e164", ...errorUtil.errToObj(message) });
}

datetime(
options?:
| string
Expand Down Expand Up @@ -1353,6 +1370,9 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
get isBase64() {
return !!this._def.checks.find((ch) => ch.kind === "base64");
}
get isE164() {
return !!this._def.checks.find((ch) => ch.kind === "e164");
}

get minLength() {
let min: number | null = null;
Expand Down