diff --git a/README.md b/README.md index 385835506..65d97314a 100644 --- a/README.md +++ b/README.md @@ -735,6 +735,8 @@ 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().date(); // ISO short date format. +z.string().time(); // time of day in 24-hour format, see below for options. z.string().ip(); // defaults to IPv4 and IPv6, see below for options // transformations @@ -768,10 +770,23 @@ z.string().includes("tuna", { message: "Must include tuna" }); z.string().startsWith("https://", { message: "Must provide secure URL" }); z.string().endsWith(".com", { message: "Only .com domains allowed" }); z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); +z.string().date({ message: "Invalid date string!" }); +z.string().time({ message: "Invalid time string!" }); z.string().ip({ message: "Invalid IP address" }); ``` -### ISO datetimes +### ISO Date, Time & Datetime validation + +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. + +The `z.string().date()` method validates strings in the format `YYYY-MM-DD`. + +The `z.string().time()` method validates strings in the format `HH:mm:ss[.SSSSSS][Z|(+|-)hh[:]mm]` +(the time portion of [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)). It defaults +to `HH:mm:ss[.SSSSSS]` validation: no timezone offsets or `Z`, with arbitrary sub-second +decimal. The `z.string().datetime()` method enforces ISO 8601; default is no timezone offsets and arbitrary sub-second decimal precision. @@ -782,6 +797,18 @@ datetime.parse("2020-01-01T00:00:00Z"); // pass datetime.parse("2020-01-01T00:00:00.123Z"); // pass datetime.parse("2020-01-01T00:00:00.123456Z"); // pass (arbitrary precision) datetime.parse("2020-01-01T00:00:00+02:00"); // fail (no offsets allowed) + +const date = z.string().date(); + +date.parse("2020-01-01"); // pass + +const time = z.string().time(); + +time.parse("00:00:00"); // pass +time.parse("09:52:31"); // pass +time.parse("23:59:59.9999999"); // pass (arbitrary precision) +time.parse("00:00:00.123Z"); // fail (no `Z` allowed) +time.parse("00:00:00.123+02:00"); // fail (no offsets allowed) ``` Timezone offsets can be allowed by setting the `offset` option to `true`. @@ -794,6 +821,13 @@ datetime.parse("2020-01-01T00:00:00.123+02:00"); // pass (millis optional) datetime.parse("2020-01-01T00:00:00.123+0200"); // pass (millis optional) datetime.parse("2020-01-01T00:00:00.123+02"); // pass (only offset hours) datetime.parse("2020-01-01T00:00:00Z"); // pass (Z still supported) + +const time = z.string().time({ offset: true }); + +time.parse("00:00:00+02:00"); // pass +time.parse("00:00:00.123+02:00"); // pass (millis optional) +time.parse("00:00:00.123+0200"); // pass (millis optional) +time.parse("00:00:00Z"); // pass (`Z` now supported) ``` You can additionally constrain the allowable `precision`. By default, arbitrary sub-second precision is supported (but optional). @@ -804,6 +838,12 @@ const datetime = z.string().datetime({ precision: 3 }); datetime.parse("2020-01-01T00:00:00.123Z"); // pass datetime.parse("2020-01-01T00:00:00Z"); // fail datetime.parse("2020-01-01T00:00:00.123456Z"); // fail + +const time = z.string().time({ precision: 3 }); + +time.parse("00:00:00.123"); // pass +time.parse("00:00:00"); // fail +time.parse("00:00:00.123456"); // fail ``` ### IP addresses diff --git a/deno/lib/README.md b/deno/lib/README.md index 385835506..aa33fa220 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -735,6 +735,8 @@ 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().date(); // ISO short date format. +z.string().time(); // time of day in 24-hour format, see below for options. z.string().ip(); // defaults to IPv4 and IPv6, see below for options // transformations @@ -768,10 +770,23 @@ z.string().includes("tuna", { message: "Must include tuna" }); z.string().startsWith("https://", { message: "Must provide secure URL" }); z.string().endsWith(".com", { message: "Only .com domains allowed" }); z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); +z.string().date({ message: "Invalid date string!" }); +z.string().time({ message: "Invalid time string!" }); z.string().ip({ message: "Invalid IP address" }); ``` -### ISO datetimes +### ISO Date, Time & Datetime validation + +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. + +The `z.string().date()` method validates strings in the format `YYYY-MM-DD`. + +The `z.string().time()` method validates strings in the format `HH:mm:ss[.SSSSSS][Z|(+|-)hh[:]mm]` +(the time portion of [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)). It defaults +to `HH:mm:ss[.SSSSSS]` validation: no timezone offsets or `Z`, with arbitrary sub-second +decimal. The `z.string().datetime()` method enforces ISO 8601; default is no timezone offsets and arbitrary sub-second decimal precision. @@ -782,6 +797,18 @@ datetime.parse("2020-01-01T00:00:00Z"); // pass datetime.parse("2020-01-01T00:00:00.123Z"); // pass datetime.parse("2020-01-01T00:00:00.123456Z"); // pass (arbitrary precision) datetime.parse("2020-01-01T00:00:00+02:00"); // fail (no offsets allowed) + +const date = z.string().date(); + +date.parse("2020-01-01"); // pass + +const time = z.string().time(); + +time.parse("00:00:00"); // pass +time.parse("09:52:31"); // pass +time.parse("23:59:59.9999999"); // pass (arbitrary precision) +time.parse("00:00:00.123Z"); // fail (no `Z` allowed) +time.parse("00:00:00.123+02:00"); // fail (no offsets allowed) ``` Timezone offsets can be allowed by setting the `offset` option to `true`. @@ -794,6 +821,13 @@ datetime.parse("2020-01-01T00:00:00.123+02:00"); // pass (millis optional) datetime.parse("2020-01-01T00:00:00.123+0200"); // pass (millis optional) datetime.parse("2020-01-01T00:00:00.123+02"); // pass (only offset hours) datetime.parse("2020-01-01T00:00:00Z"); // pass (Z still supported) + +const time = z.string().time({ offset: true }); + +time.parse("00:00:00+02:00"); // pass +time.parse("00:00:00.123+02:00"); // pass (millis optional) +time.parse("00:00:00.123+0200"); // pass (millis optional) +time.parse("00:00:00Z"); // pass (`Z` now supported) ``` You can additionally constrain the allowable `precision`. By default, arbitrary sub-second precision is supported (but optional). @@ -804,6 +838,12 @@ const datetime = z.string().datetime({ precision: 3 }); datetime.parse("2020-01-01T00:00:00.123Z"); // pass datetime.parse("2020-01-01T00:00:00Z"); // fail datetime.parse("2020-01-01T00:00:00.123456Z"); // fail + +const time = z.string().time({ precision: 3 }); + +time.parse("00:00:00.123"); // pass +time.parse("00:00:00"); // fail +time.parse("00:00:00.123456"); // fail ``` ### IP addresses diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index 8c0ff3f9f..bc4ca2aa0 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -98,6 +98,8 @@ export type StringValidation = | "cuid2" | "ulid" | "datetime" + | "date" + | "time" | "ip" | { includes: string; position?: number } | { startsWith: string } diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 2dc5b79a6..36549036d 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -470,6 +470,88 @@ test("datetime parsing", () => { ).toThrow(); }); +test("date", () => { + const a = z.string().date(); + expect(a.isDate).toEqual(true); +}); + +test("date parsing", () => { + const date = z.string().date(); + date.parse("1970-01-01"); + date.parse("2022-10-13"); + expect(() => date.parse("")).toThrow(); + expect(() => date.parse("foo")).toThrow(); + expect(() => date.parse("200-01-01")).toThrow(); + expect(() => date.parse("20000-01-01")).toThrow(); + expect(() => date.parse("2000-0-01")).toThrow(); + expect(() => date.parse("2000-011-01")).toThrow(); + expect(() => date.parse("2000-01-0")).toThrow(); + expect(() => date.parse("2000-01-011")).toThrow(); + expect(() => date.parse("2000/01/01")).toThrow(); + expect(() => date.parse("01-01-2022")).toThrow(); + expect(() => date.parse("01/01/2022")).toThrow(); + expect(() => date.parse("2000-01-01 00:00:00Z")).toThrow(); + expect(() => date.parse("2020-10-14T17:42:29+00:00")).toThrow(); + expect(() => date.parse("2020-10-14T17:42:29Z")).toThrow(); + expect(() => date.parse("2020-10-14T17:42:29")).toThrow(); + expect(() => date.parse("2020-10-14T17:42:29.123Z")).toThrow(); +}); + +test("time", () => { + const a = z.string().time(); + expect(a.isTime).toEqual(true); +}); + +test("time parsing", () => { + const time = z.string().time(); + time.parse("00:00:00"); + time.parse("09:52:31"); + time.parse("23:59:59.9999999"); + expect(() => time.parse("")).toThrow(); + expect(() => time.parse("foo")).toThrow(); + expect(() => time.parse("00:00:00Z")).toThrow(); + expect(() => time.parse("0:00:00")).toThrow(); + expect(() => time.parse("00:0:00")).toThrow(); + expect(() => time.parse("00:00:0")).toThrow(); + expect(() => time.parse("00:00:00.000+00:00")).toThrow(); + + const time2 = z.string().time({ precision: 2 }); + time2.parse("00:00:00.00"); + time2.parse("09:52:31.12"); + time2.parse("23:59:59.99"); + expect(() => time2.parse("")).toThrow(); + expect(() => time2.parse("foo")).toThrow(); + expect(() => time2.parse("00:00:00")).toThrow(); + expect(() => time2.parse("00:00:00.00Z")).toThrow(); + expect(() => time2.parse("00:00:00.0")).toThrow(); + expect(() => time2.parse("00:00:00.000")).toThrow(); + expect(() => time2.parse("00:00:00.00+00:00")).toThrow(); + + const time3 = z.string().time({ offset: true }); + time3.parse("00:00:00Z"); + time3.parse("09:52:31Z"); + time3.parse("00:00:00+00:00"); + time3.parse("00:00:00+0000"); + time3.parse("00:00:00.000Z"); + time3.parse("00:00:00.000+00:00"); + time3.parse("00:00:00.000+0000"); + expect(() => time3.parse("")).toThrow(); + expect(() => time3.parse("foo")).toThrow(); + expect(() => time3.parse("00:00:00")).toThrow(); + expect(() => time3.parse("00:00:00.000")).toThrow(); + + const time4 = z.string().time({ offset: true, precision: 0 }); + time4.parse("00:00:00Z"); + time4.parse("09:52:31Z"); + time4.parse("00:00:00+00:00"); + time4.parse("00:00:00+0000"); + expect(() => time4.parse("")).toThrow(); + expect(() => time4.parse("foo")).toThrow(); + expect(() => time4.parse("00:00:00.0")).toThrow(); + expect(() => time4.parse("00:00:00.000")).toThrow(); + expect(() => time4.parse("00:00:00.000+00:00")).toThrow(); +}); + test("IP validation", () => { const ip = z.string().ip(); expect(ip.safeParse("122.122.122.122").success).toBe(true); diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 673ab1ec9..a086bf8d2 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -535,6 +535,16 @@ export type ZodStringCheck = kind: "datetime"; offset: boolean; precision: number | null; + withDate: true; + withTime: true; + message?: string; + } + | { kind: "date"; withDate: true; message?: string } + | { + kind: "time"; + offset: boolean; + precision: number | null; + withTime: true; message?: string; } | { kind: "ip"; version?: IpVersion; message?: string }; @@ -580,36 +590,44 @@ 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})))$/; // Adapted from https://stackoverflow.com/a/3143231 -const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { - if (args.precision) { - if (args.offset) { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}(([+-]\\d{2}(:?\\d{2})?)|Z)$` - ); - } else { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}Z$` - ); - } - } else if (args.precision === 0) { - if (args.offset) { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(([+-]\\d{2}(:?\\d{2})?)|Z)$` - ); - } else { - return new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$`); - } - } else { - if (args.offset) { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)$` - ); - } else { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$` - ); +const datetimeRegex = (args: { + precision?: number | null; + offset?: boolean; + withDate?: boolean; + withTime?: boolean; +}) => { + let regex = "^"; + + if (args.withDate) { + regex = `${regex}\\d{4}-\\d{2}-\\d{2}`; + + if (args.withTime) { + regex = `${regex}T`; } } + + if (!args.withTime) { + return new RegExp(`${regex}$`); + } + + regex = `${regex}\\d{2}:\\d{2}:\\d{2}`; + + if (args.precision) { + regex = `${regex}\\.\\d{${args.precision}}`; + } else if (args.precision == null) { + regex = `${regex}(\\.\\d+)?`; + } + // in case of `precision = 0`, don't add the decimal part. + + if (args.offset) { + return new RegExp(`${regex}(([+-]\\d{2}(:?\\d{2})?)|Z)$`); + } + + if (args.withDate) { + return new RegExp(`${regex}Z$`); + } + + return new RegExp(`${regex}$`); }; function isValidIP(ip: string, version?: IpVersion) { @@ -632,15 +650,11 @@ export class ZodString extends ZodType { if (parsedType !== ZodParsedType.string) { const ctx = this._getOrReturnCtx(input); - addIssueToContext( - ctx, - { - code: ZodIssueCode.invalid_type, - expected: ZodParsedType.string, - received: ctx.parsedType, - } - // - ); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.string, + received: ctx.parsedType, + }); return INVALID; } @@ -835,6 +849,30 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "date") { + const regex = datetimeRegex(check); + + if (!regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: "date", + message: check.message, + }); + status.dirty(); + } + } else if (check.kind === "time") { + const regex = datetimeRegex(check); + + if (!regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: "time", + message: check.message, + }); + status.dirty(); + } } else if (check.kind === "ip") { if (!isValidIP(input.data, check.version)) { ctx = this._getOrReturnCtx(input, ctx); @@ -875,18 +913,23 @@ export class ZodString extends ZodType { email(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "email", ...errorUtil.errToObj(message) }); } + url(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "url", ...errorUtil.errToObj(message) }); } + emoji(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "emoji", ...errorUtil.errToObj(message) }); } + uuid(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "uuid", ...errorUtil.errToObj(message) }); } + cuid(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "cuid", ...errorUtil.errToObj(message) }); } + cuid2(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "cuid2", ...errorUtil.errToObj(message) }); } @@ -912,6 +955,8 @@ export class ZodString extends ZodType { kind: "datetime", precision: null, offset: false, + withDate: true, + withTime: true, message: options, }); } @@ -920,6 +965,40 @@ export class ZodString extends ZodType { precision: typeof options?.precision === "undefined" ? null : options?.precision, offset: options?.offset ?? false, + withDate: true, + withTime: true, + ...errorUtil.errToObj(options?.message), + }); + } + + date(message?: string) { + return this._addCheck({ kind: "date", withDate: true, message }); + } + + time( + options?: + | string + | { + message?: string | undefined; + precision?: number | null; + offset?: boolean; + } + ) { + if (typeof options === "string") { + return this._addCheck({ + kind: "time", + precision: null, + offset: false, + withTime: true, + message: options, + }); + } + return this._addCheck({ + kind: "time", + precision: + typeof options?.precision === "undefined" ? null : options?.precision, + offset: options?.offset ?? false, + withTime: true, ...errorUtil.errToObj(options?.message), }); } @@ -1014,21 +1093,34 @@ export class ZodString extends ZodType { return !!this._def.checks.find((ch) => ch.kind === "datetime"); } + get isDate() { + return !!this._def.checks.find((ch) => ch.kind === "date"); + } + + get isTime() { + return !!this._def.checks.find((ch) => ch.kind === "time"); + } + get isEmail() { return !!this._def.checks.find((ch) => ch.kind === "email"); } + get isURL() { return !!this._def.checks.find((ch) => ch.kind === "url"); } + get isEmoji() { return !!this._def.checks.find((ch) => ch.kind === "emoji"); } + get isUUID() { return !!this._def.checks.find((ch) => ch.kind === "uuid"); } + get isCUID() { return !!this._def.checks.find((ch) => ch.kind === "cuid"); } + get isCUID2() { return !!this._def.checks.find((ch) => ch.kind === "cuid2"); } @@ -1048,6 +1140,7 @@ export class ZodString extends ZodType { } return min; } + get maxLength() { let max: number | null = null; for (const ch of this._def.checks) { diff --git a/src/ZodError.ts b/src/ZodError.ts index ef7cea168..31215c365 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -98,6 +98,8 @@ export type StringValidation = | "cuid2" | "ulid" | "datetime" + | "date" + | "time" | "ip" | { includes: string; position?: number } | { startsWith: string } diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index 43e348b7b..9063a8bf2 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -469,6 +469,88 @@ test("datetime parsing", () => { ).toThrow(); }); +test("date", () => { + const a = z.string().date(); + expect(a.isDate).toEqual(true); +}); + +test("date parsing", () => { + const date = z.string().date(); + date.parse("1970-01-01"); + date.parse("2022-10-13"); + expect(() => date.parse("")).toThrow(); + expect(() => date.parse("foo")).toThrow(); + expect(() => date.parse("200-01-01")).toThrow(); + expect(() => date.parse("20000-01-01")).toThrow(); + expect(() => date.parse("2000-0-01")).toThrow(); + expect(() => date.parse("2000-011-01")).toThrow(); + expect(() => date.parse("2000-01-0")).toThrow(); + expect(() => date.parse("2000-01-011")).toThrow(); + expect(() => date.parse("2000/01/01")).toThrow(); + expect(() => date.parse("01-01-2022")).toThrow(); + expect(() => date.parse("01/01/2022")).toThrow(); + expect(() => date.parse("2000-01-01 00:00:00Z")).toThrow(); + expect(() => date.parse("2020-10-14T17:42:29+00:00")).toThrow(); + expect(() => date.parse("2020-10-14T17:42:29Z")).toThrow(); + expect(() => date.parse("2020-10-14T17:42:29")).toThrow(); + expect(() => date.parse("2020-10-14T17:42:29.123Z")).toThrow(); +}); + +test("time", () => { + const a = z.string().time(); + expect(a.isTime).toEqual(true); +}); + +test("time parsing", () => { + const time = z.string().time(); + time.parse("00:00:00"); + time.parse("09:52:31"); + time.parse("23:59:59.9999999"); + expect(() => time.parse("")).toThrow(); + expect(() => time.parse("foo")).toThrow(); + expect(() => time.parse("00:00:00Z")).toThrow(); + expect(() => time.parse("0:00:00")).toThrow(); + expect(() => time.parse("00:0:00")).toThrow(); + expect(() => time.parse("00:00:0")).toThrow(); + expect(() => time.parse("00:00:00.000+00:00")).toThrow(); + + const time2 = z.string().time({ precision: 2 }); + time2.parse("00:00:00.00"); + time2.parse("09:52:31.12"); + time2.parse("23:59:59.99"); + expect(() => time2.parse("")).toThrow(); + expect(() => time2.parse("foo")).toThrow(); + expect(() => time2.parse("00:00:00")).toThrow(); + expect(() => time2.parse("00:00:00.00Z")).toThrow(); + expect(() => time2.parse("00:00:00.0")).toThrow(); + expect(() => time2.parse("00:00:00.000")).toThrow(); + expect(() => time2.parse("00:00:00.00+00:00")).toThrow(); + + const time3 = z.string().time({ offset: true }); + time3.parse("00:00:00Z"); + time3.parse("09:52:31Z"); + time3.parse("00:00:00+00:00"); + time3.parse("00:00:00+0000"); + time3.parse("00:00:00.000Z"); + time3.parse("00:00:00.000+00:00"); + time3.parse("00:00:00.000+0000"); + expect(() => time3.parse("")).toThrow(); + expect(() => time3.parse("foo")).toThrow(); + expect(() => time3.parse("00:00:00")).toThrow(); + expect(() => time3.parse("00:00:00.000")).toThrow(); + + const time4 = z.string().time({ offset: true, precision: 0 }); + time4.parse("00:00:00Z"); + time4.parse("09:52:31Z"); + time4.parse("00:00:00+00:00"); + time4.parse("00:00:00+0000"); + expect(() => time4.parse("")).toThrow(); + expect(() => time4.parse("foo")).toThrow(); + expect(() => time4.parse("00:00:00.0")).toThrow(); + expect(() => time4.parse("00:00:00.000")).toThrow(); + expect(() => time4.parse("00:00:00.000+00:00")).toThrow(); +}); + test("IP validation", () => { const ip = z.string().ip(); expect(ip.safeParse("122.122.122.122").success).toBe(true); diff --git a/src/types.ts b/src/types.ts index 567936b76..a418c50b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -535,6 +535,16 @@ export type ZodStringCheck = kind: "datetime"; offset: boolean; precision: number | null; + withDate: true; + withTime: true; + message?: string; + } + | { kind: "date"; withDate: true; message?: string } + | { + kind: "time"; + offset: boolean; + precision: number | null; + withTime: true; message?: string; } | { kind: "ip"; version?: IpVersion; message?: string }; @@ -580,36 +590,44 @@ 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})))$/; // Adapted from https://stackoverflow.com/a/3143231 -const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { - if (args.precision) { - if (args.offset) { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}(([+-]\\d{2}(:?\\d{2})?)|Z)$` - ); - } else { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}Z$` - ); - } - } else if (args.precision === 0) { - if (args.offset) { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(([+-]\\d{2}(:?\\d{2})?)|Z)$` - ); - } else { - return new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$`); - } - } else { - if (args.offset) { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)$` - ); - } else { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$` - ); +const datetimeRegex = (args: { + precision?: number | null; + offset?: boolean; + withDate?: boolean; + withTime?: boolean; +}) => { + let regex = "^"; + + if (args.withDate) { + regex = `${regex}\\d{4}-\\d{2}-\\d{2}`; + + if (args.withTime) { + regex = `${regex}T`; } } + + if (!args.withTime) { + return new RegExp(`${regex}$`); + } + + regex = `${regex}\\d{2}:\\d{2}:\\d{2}`; + + if (args.precision) { + regex = `${regex}\\.\\d{${args.precision}}`; + } else if (args.precision == null) { + regex = `${regex}(\\.\\d+)?`; + } + // in case of `precision = 0`, don't add the decimal part. + + if (args.offset) { + return new RegExp(`${regex}(([+-]\\d{2}(:?\\d{2})?)|Z)$`); + } + + if (args.withDate) { + return new RegExp(`${regex}Z$`); + } + + return new RegExp(`${regex}$`); }; function isValidIP(ip: string, version?: IpVersion) { @@ -632,15 +650,11 @@ export class ZodString extends ZodType { if (parsedType !== ZodParsedType.string) { const ctx = this._getOrReturnCtx(input); - addIssueToContext( - ctx, - { - code: ZodIssueCode.invalid_type, - expected: ZodParsedType.string, - received: ctx.parsedType, - } - // - ); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.string, + received: ctx.parsedType, + }); return INVALID; } @@ -835,6 +849,30 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "date") { + const regex = datetimeRegex(check); + + if (!regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: "date", + message: check.message, + }); + status.dirty(); + } + } else if (check.kind === "time") { + const regex = datetimeRegex(check); + + if (!regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: "time", + message: check.message, + }); + status.dirty(); + } } else if (check.kind === "ip") { if (!isValidIP(input.data, check.version)) { ctx = this._getOrReturnCtx(input, ctx); @@ -875,18 +913,23 @@ export class ZodString extends ZodType { email(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "email", ...errorUtil.errToObj(message) }); } + url(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "url", ...errorUtil.errToObj(message) }); } + emoji(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "emoji", ...errorUtil.errToObj(message) }); } + uuid(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "uuid", ...errorUtil.errToObj(message) }); } + cuid(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "cuid", ...errorUtil.errToObj(message) }); } + cuid2(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "cuid2", ...errorUtil.errToObj(message) }); } @@ -912,6 +955,8 @@ export class ZodString extends ZodType { kind: "datetime", precision: null, offset: false, + withDate: true, + withTime: true, message: options, }); } @@ -920,6 +965,40 @@ export class ZodString extends ZodType { precision: typeof options?.precision === "undefined" ? null : options?.precision, offset: options?.offset ?? false, + withDate: true, + withTime: true, + ...errorUtil.errToObj(options?.message), + }); + } + + date(message?: string) { + return this._addCheck({ kind: "date", withDate: true, message }); + } + + time( + options?: + | string + | { + message?: string | undefined; + precision?: number | null; + offset?: boolean; + } + ) { + if (typeof options === "string") { + return this._addCheck({ + kind: "time", + precision: null, + offset: false, + withTime: true, + message: options, + }); + } + return this._addCheck({ + kind: "time", + precision: + typeof options?.precision === "undefined" ? null : options?.precision, + offset: options?.offset ?? false, + withTime: true, ...errorUtil.errToObj(options?.message), }); } @@ -1014,21 +1093,34 @@ export class ZodString extends ZodType { return !!this._def.checks.find((ch) => ch.kind === "datetime"); } + get isDate() { + return !!this._def.checks.find((ch) => ch.kind === "date"); + } + + get isTime() { + return !!this._def.checks.find((ch) => ch.kind === "time"); + } + get isEmail() { return !!this._def.checks.find((ch) => ch.kind === "email"); } + get isURL() { return !!this._def.checks.find((ch) => ch.kind === "url"); } + get isEmoji() { return !!this._def.checks.find((ch) => ch.kind === "emoji"); } + get isUUID() { return !!this._def.checks.find((ch) => ch.kind === "uuid"); } + get isCUID() { return !!this._def.checks.find((ch) => ch.kind === "cuid"); } + get isCUID2() { return !!this._def.checks.find((ch) => ch.kind === "cuid2"); } @@ -1048,6 +1140,7 @@ export class ZodString extends ZodType { } return min; } + get maxLength() { let max: number | null = null; for (const ch of this._def.checks) {