Skip to content

Commit

Permalink
feat: Add support for ISO-8601 Durations (#3265)
Browse files Browse the repository at this point in the history
* feat: Add support for ISO-8601 Durations

https://en.wikipedia.org/wiki/ISO_8601#Durations
As an extension of the ISO standard, the format is also used in RFC 3339, XML Schema Part 2, TC39's Temporal proposal, and a format for JSON Schema strings since draft 2019-09.

* Update Duration to better support ISO 8601-2

Use a better regex to support negatives and decimal fractions to the smallest value.
Add a lot of valid and invalid inputs to the test.

* Fix test

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
  • Loading branch information
mastermatt and colinhacks authored Apr 17, 2024
1 parent 8d0a4b3 commit 0ca48b2
Show file tree
Hide file tree
Showing 11 changed files with 157 additions and 20 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -858,10 +858,11 @@ z.string().endsWith(string);
z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
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().ip(); // defaults to allow both IPv4 and IPv6
z.string().base64();

// transformations
// transforms
z.string().trim(); // trim whitespace
z.string().toLowerCase(); // toLowerCase
z.string().toUpperCase(); // toUpperCase
Expand Down
1 change: 1 addition & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ z.string().uuid();
z.string().cuid();
z.string().cuid2();
z.string().ulid();
z.string().duration();
z.string().regex(regex);
z.string().includes(string);
z.string().startsWith(string);
Expand Down
3 changes: 2 additions & 1 deletion deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -858,10 +858,11 @@ z.string().endsWith(string);
z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
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().ip(); // defaults to allow both IPv4 and IPv6
z.string().base64();

// transformations
// transforms
z.string().trim(); // trim whitespace
z.string().toLowerCase(); // toLowerCase
z.string().toUpperCase(); // toUpperCase
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export type StringValidation =
| "datetime"
| "date"
| "time"
| "duration"
| "ip"
| "base64"
| { includes: string; position?: number }
Expand Down
54 changes: 54 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,60 @@ test("time parsing", () => {
// expect(() => time4.parse("00:00:00.000+00:00")).toThrow();
});

test("duration", () => {
const duration = z.string().duration();
expect(duration.isDuration).toEqual(true);

const validDurations = [
"P3Y6M4DT12H30M5S",
"P2Y9M3DT12H31M8.001S",
"+P3Y6M4DT12H30M5S",
"-PT0.001S",
"+PT0.001S",
"PT0,001S",
"PT12H30M5S",
"-P2M1D",
"P-2M-1D",
"-P5DT10H",
"P-5DT-10H",
"P1Y",
"P2MT30M",
"PT6H",
"P5W",
"P0.5Y",
"P0,5Y",
"P42YT7.004M",
];

const invalidDurations = [
"foo bar",
"",
" ",
"P",
"T1H",
"P0.5Y1D",
"P0,5Y6M",
"P1YT",
];

for (const val of validDurations) {
const result = duration.safeParse(val);
if (!result.success) {
throw Error(`Valid duration could not be parsed: ${val}`);
}
}

for (const val of invalidDurations) {
const result = duration.safeParse(val);

if (result.success) {
throw Error(`Invalid duration was successful parsed: ${val}`);
}

expect(result.error.issues[0].message).toEqual("Invalid duration");
}
});

test("IP validation", () => {
const ip = z.string().ip();
expect(ip.safeParse("122.122.122.122").success).toBe(true);
Expand Down
9 changes: 0 additions & 9 deletions deno/lib/benchmarks/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@ const DATA = "2021-01-01";
const MONTHS_31 = new Set([1, 3, 5, 7, 8, 10, 12]);
const MONTHS_30 = new Set([4, 6, 9, 11]);

function generateRandomDatetime(): string {
const year = Math.floor(Math.random() * 3000);
const month = Math.floor(Math.random() * 12) + 1;
const day = Math.floor(Math.random() * 31) + 1;
return `${year}-${month.toString().padStart(2, "0")}-${day
.toString()
.padStart(2, "0")}`;
}

const simpleDatetimeRegex = /^(\d{4})-(\d{2})-(\d{2})$/;
const datetimeRegexNoLeapYearValidation =
/^\d{4}-((0[13578]|10|12)-31|(0[13-9]|1[0-2])-30|(0[1-9]|1[0-2])-(0[1-9]|1\d|2\d))$/;
Expand Down
21 changes: 21 additions & 0 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string };

Expand All @@ -580,6 +581,9 @@ const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const uuidRegex =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i;
const nanoidRegex = /^[a-z0-9_-]{21}$/i;
const durationRegex =
/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/;

// from https://stackoverflow.com/a/46181/1550155
// old version: too slow, didn't support unicode
// const emailRegex = /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i;
Expand Down Expand Up @@ -906,6 +910,16 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
status.dirty();
}
} else if (check.kind === "duration") {
if (!durationRegex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "duration",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else if (check.kind === "ip") {
if (!isValidIP(input.data, check.version)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down Expand Up @@ -1046,6 +1060,10 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
}

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

regex(regex: RegExp, message?: errorUtil.ErrMessage) {
return this._addCheck({
kind: "regex",
Expand Down Expand Up @@ -1143,6 +1161,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
get isTime() {
return !!this._def.checks.find((ch) => ch.kind === "time");
}
get isDuration() {
return !!this._def.checks.find((ch) => ch.kind === "duration");
}

get isEmail() {
return !!this._def.checks.find((ch) => ch.kind === "email");
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export type StringValidation =
| "datetime"
| "date"
| "time"
| "duration"
| "ip"
| "base64"
| { includes: string; position?: number }
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,60 @@ test("time parsing", () => {
// expect(() => time4.parse("00:00:00.000+00:00")).toThrow();
});

test("duration", () => {
const duration = z.string().duration();
expect(duration.isDuration).toEqual(true);

const validDurations = [
"P3Y6M4DT12H30M5S",
"P2Y9M3DT12H31M8.001S",
"+P3Y6M4DT12H30M5S",
"-PT0.001S",
"+PT0.001S",
"PT0,001S",
"PT12H30M5S",
"-P2M1D",
"P-2M-1D",
"-P5DT10H",
"P-5DT-10H",
"P1Y",
"P2MT30M",
"PT6H",
"P5W",
"P0.5Y",
"P0,5Y",
"P42YT7.004M",
];

const invalidDurations = [
"foo bar",
"",
" ",
"P",
"T1H",
"P0.5Y1D",
"P0,5Y6M",
"P1YT",
];

for (const val of validDurations) {
const result = duration.safeParse(val);
if (!result.success) {
throw Error(`Valid duration could not be parsed: ${val}`);
}
}

for (const val of invalidDurations) {
const result = duration.safeParse(val);

if (result.success) {
throw Error(`Invalid duration was successful parsed: ${val}`);
}

expect(result.error.issues[0].message).toEqual("Invalid duration");
}
});

test("IP validation", () => {
const ip = z.string().ip();
expect(ip.safeParse("122.122.122.122").success).toBe(true);
Expand Down
9 changes: 0 additions & 9 deletions src/benchmarks/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@ const DATA = "2021-01-01";
const MONTHS_31 = new Set([1, 3, 5, 7, 8, 10, 12]);
const MONTHS_30 = new Set([4, 6, 9, 11]);

function generateRandomDatetime(): string {
const year = Math.floor(Math.random() * 3000);
const month = Math.floor(Math.random() * 12) + 1;
const day = Math.floor(Math.random() * 31) + 1;
return `${year}-${month.toString().padStart(2, "0")}-${day
.toString()
.padStart(2, "0")}`;
}

const simpleDatetimeRegex = /^(\d{4})-(\d{2})-(\d{2})$/;
const datetimeRegexNoLeapYearValidation =
/^\d{4}-((0[13578]|10|12)-31|(0[13-9]|1[0-2])-30|(0[1-9]|1[0-2])-(0[1-9]|1\d|2\d))$/;
Expand Down
21 changes: 21 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string };

Expand All @@ -580,6 +581,9 @@ const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const uuidRegex =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i;
const nanoidRegex = /^[a-z0-9_-]{21}$/i;
const durationRegex =
/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/;

// from https://stackoverflow.com/a/46181/1550155
// old version: too slow, didn't support unicode
// const emailRegex = /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i;
Expand Down Expand Up @@ -906,6 +910,16 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
status.dirty();
}
} else if (check.kind === "duration") {
if (!durationRegex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "duration",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else if (check.kind === "ip") {
if (!isValidIP(input.data, check.version)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down Expand Up @@ -1046,6 +1060,10 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
}

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

regex(regex: RegExp, message?: errorUtil.ErrMessage) {
return this._addCheck({
kind: "regex",
Expand Down Expand Up @@ -1143,6 +1161,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
get isTime() {
return !!this._def.checks.find((ch) => ch.kind === "time");
}
get isDuration() {
return !!this._def.checks.find((ch) => ch.kind === "duration");
}

get isEmail() {
return !!this._def.checks.find((ch) => ch.kind === "email");
Expand Down

0 comments on commit 0ca48b2

Please sign in to comment.