Skip to content

Commit

Permalink
feat: Add support for ISO-8601 Durations
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mastermatt committed Feb 23, 2024
1 parent e5e8619 commit 80d41e8
Show file tree
Hide file tree
Showing 9 changed files with 75 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,7 @@ z.string().includes(string);
z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; default is without UTC offset, see below for options
z.string().duration(); // ISO 8601 Duration
z.string().ip(); // defaults to IPv4 and IPv6, see below for options

// transformations
Expand Down
1 change: 1 addition & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ z.string().length(5);
z.string().email();
z.string().url();
z.string().uuid();
z.string().duration();
z.string().regex(regex);
z.string().startsWith(string);
z.string().endsWith(string);
Expand Down
1 change: 1 addition & 0 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,7 @@ z.string().includes(string);
z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; default is without UTC offset, see below for options
z.string().duration(); // ISO 8601 Duration
z.string().ip(); // defaults to IPv4 and IPv6, see below for options

// transformations
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export type StringValidation =
| "cuid2"
| "ulid"
| "datetime"
| "duration"
| "ip"
| { includes: string; position?: number }
| { startsWith: string }
Expand Down
13 changes: 13 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,19 @@ test("datetime parsing", () => {
).toThrow();
});

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

duration.parse("P3Y6M4DT12H30M5S");

const result = duration.safeParse("invalidDuration");
expect(result.success).toEqual(false);
if (!result.success) {
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
22 changes: 22 additions & 0 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string };

export interface ZodStringDef extends ZodTypeDef {
Expand All @@ -552,6 +553,9 @@ const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
// /^([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}|00000000-0000-0000-0000-000000000000)$/i;
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 durationRegex =
/^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\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 @@ -835,6 +839,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 @@ -924,6 +938,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 @@ -1014,6 +1032,10 @@ export class ZodString extends ZodType<string, ZodStringDef> {
return !!this._def.checks.find((ch) => ch.kind === "datetime");
}

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 @@ -98,6 +98,7 @@ export type StringValidation =
| "cuid2"
| "ulid"
| "datetime"
| "duration"
| "ip"
| { includes: string; position?: number }
| { startsWith: string }
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,19 @@ test("datetime parsing", () => {
).toThrow();
});

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

duration.parse("P3Y6M4DT12H30M5S");

const result = duration.safeParse("invalidDuration");
expect(result.success).toEqual(false);
if (!result.success) {
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
22 changes: 22 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string };

export interface ZodStringDef extends ZodTypeDef {
Expand All @@ -552,6 +553,9 @@ const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
// /^([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}|00000000-0000-0000-0000-000000000000)$/i;
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 durationRegex =
/^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\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 @@ -835,6 +839,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 @@ -924,6 +938,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 @@ -1014,6 +1032,10 @@ export class ZodString extends ZodType<string, ZodStringDef> {
return !!this._def.checks.find((ch) => ch.kind === "datetime");
}

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

Please sign in to comment.