diff --git a/README.md b/README.md index 7ec48d71b..c2c85885b 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,8 @@ z.string().url(); z.string().uuid(); z.string().cuid(); z.string().regex(regex); +z.string().startsWith(string); +z.string().endsWith(string); // trim whitespace z.string().trim(); @@ -484,6 +486,8 @@ z.string().length(5, { message: "Must be exactly 5 characters long" }); z.string().email({ message: "Invalid email address" }); z.string().url({ message: "Invalid url" }); z.string().uuid({ message: "Invalid UUID" }); +z.string().startsWith("https://", { message: "Must provide secure URL" }); +z.string().endsWith(".com", { message: "Only .com domains allowed" }); ``` ## Numbers diff --git a/README_ZH.md b/README_ZH.md index eadd15afe..b4bdb5463 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -391,6 +391,8 @@ z.string().email(); z.string().url(); z.string().uuid(); z.string().regex(regex); +z.string().startsWith(string); +z.string().endsWith(string); // 已废弃,等同于 .min(1) z.string().nonempty(); @@ -409,6 +411,8 @@ z.string().length(5, { message: "Must be exactly 5 characters long" }); z.string().email({ message: "Invalid email address." }); z.string().url({ message: "Invalid url" }); z.string().uuid({ message: "Invalid UUID" }); +z.string().startsWith("https://", { message: "Must provide secure URL" }); +z.string().endsWith(".com", { message: "Only .com domains allowed" }); ``` ## Numbers @@ -555,9 +559,9 @@ const user = z.object({ const deepPartialUser = user.deepPartial(); -/* +/* { - username?: string | undefined, + username?: string | undefined, location?: { latitude?: number | undefined; longitude?: number | undefined; @@ -884,12 +888,12 @@ const FishEnum = z.enum(fish); FishEnum.enum.Salmon; // => 自动补全 FishEnum.enum; -/* +/* => { Salmon: "Salmon", Tuna: "Tuna", Trout: "Trout", -} +} */ ``` diff --git a/deno/lib/README.md b/deno/lib/README.md index 7ec48d71b..c2c85885b 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -453,6 +453,8 @@ z.string().url(); z.string().uuid(); z.string().cuid(); z.string().regex(regex); +z.string().startsWith(string); +z.string().endsWith(string); // trim whitespace z.string().trim(); @@ -484,6 +486,8 @@ z.string().length(5, { message: "Must be exactly 5 characters long" }); z.string().email({ message: "Invalid email address" }); z.string().url({ message: "Invalid url" }); z.string().uuid({ message: "Invalid UUID" }); +z.string().startsWith("https://", { message: "Must provide secure URL" }); +z.string().endsWith(".com", { message: "Only .com domains allowed" }); ``` ## Numbers diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index dfbea6118..75bb8629e 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -86,7 +86,14 @@ export interface ZodInvalidDateIssue extends ZodIssueBase { code: typeof ZodIssueCode.invalid_date; } -export type StringValidation = "email" | "url" | "uuid" | "regex" | "cuid"; +export type StringValidation = + | "email" + | "url" + | "uuid" + | "regex" + | "cuid" + | { startsWith: string } + | { endsWith: string }; export interface ZodInvalidStringIssue extends ZodIssueBase { code: typeof ZodIssueCode.invalid_string; @@ -344,8 +351,19 @@ export const defaultErrorMap = ( message = `Invalid date`; break; case ZodIssueCode.invalid_string: - if (issue.validation !== "regex") message = `Invalid ${issue.validation}`; - else message = "Invalid"; + if (typeof issue.validation === "object") { + if ("startsWith" in issue.validation) { + message = `Invalid input: must start with "${issue.validation.startsWith}"`; + } else if ("endsWith" in issue.validation) { + message = `Invalid input: must start with "${issue.validation.endsWith}"`; + } else { + util.assertNever(issue.validation); + } + } else if (issue.validation !== "regex") { + message = `Invalid ${issue.validation}`; + } else { + message = "Invalid"; + } break; case ZodIssueCode.too_small: if (issue.type === "array") diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 9234da33f..f33779aa3 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -8,6 +8,8 @@ const minFive = z.string().min(5, "min5"); const maxFive = z.string().max(5, "max5"); const justFive = z.string().length(5); const nonempty = z.string().nonempty("nonempty"); +const startsWith = z.string().startsWith("startsWith"); +const endsWith = z.string().endsWith("endsWith"); test("passing validations", () => { minFive.parse("12345"); @@ -16,6 +18,8 @@ test("passing validations", () => { maxFive.parse("1234"); nonempty.parse("1"); justFive.parse("12345"); + startsWith.parse("startsWithX"); + endsWith.parse("XendsWith"); }); test("failing validations", () => { @@ -24,6 +28,8 @@ test("failing validations", () => { expect(() => nonempty.parse("")).toThrow(); expect(() => justFive.parse("1234")).toThrow(); expect(() => justFive.parse("123456")).toThrow(); + expect(() => startsWith.parse("x")).toThrow(); + expect(() => endsWith.parse("x")).toThrow(); }); test("email validations", () => { diff --git a/deno/lib/types.ts b/deno/lib/types.ts index ca8cd0222..ac179c295 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -450,6 +450,8 @@ type ZodStringCheck = | { kind: "url"; message?: string } | { kind: "uuid"; message?: string } | { kind: "cuid"; message?: string } + | { kind: "startsWith"; value: string; message?: string } + | { kind: "endsWith"; value: string; message?: string } | { kind: "regex"; regex: RegExp; message?: string } | { kind: "trim"; message?: string }; @@ -570,6 +572,26 @@ export class ZodString extends ZodType { } } else if (check.kind === "trim") { input.data = input.data.trim(); + } else if (check.kind === "startsWith") { + if (!(input.data as string).startsWith(check.value)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { startsWith: check.value }, + message: check.message, + }); + status.dirty(); + } + } else if (check.kind === "endsWith") { + if (!(input.data as string).endsWith(check.value)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { endsWith: check.value }, + message: check.message, + }); + status.dirty(); + } } else { util.assertNever(check); } @@ -616,6 +638,22 @@ export class ZodString extends ZodType { }); } + startsWith(value: string, message?: errorUtil.ErrMessage) { + return this._addCheck({ + kind: "startsWith", + value: value, + ...errorUtil.errToObj(message), + }); + } + + endsWith(value: string, message?: errorUtil.ErrMessage) { + return this._addCheck({ + kind: "endsWith", + value: value, + ...errorUtil.errToObj(message), + }); + } + min(minLength: number, message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "min", diff --git a/src/ZodError.ts b/src/ZodError.ts index 1302ff413..fe1422353 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -86,7 +86,14 @@ export interface ZodInvalidDateIssue extends ZodIssueBase { code: typeof ZodIssueCode.invalid_date; } -export type StringValidation = "email" | "url" | "uuid" | "regex" | "cuid"; +export type StringValidation = + | "email" + | "url" + | "uuid" + | "regex" + | "cuid" + | { startsWith: string } + | { endsWith: string }; export interface ZodInvalidStringIssue extends ZodIssueBase { code: typeof ZodIssueCode.invalid_string; @@ -344,8 +351,19 @@ export const defaultErrorMap = ( message = `Invalid date`; break; case ZodIssueCode.invalid_string: - if (issue.validation !== "regex") message = `Invalid ${issue.validation}`; - else message = "Invalid"; + if (typeof issue.validation === "object") { + if ("startsWith" in issue.validation) { + message = `Invalid input: must start with "${issue.validation.startsWith}"`; + } else if ("endsWith" in issue.validation) { + message = `Invalid input: must start with "${issue.validation.endsWith}"`; + } else { + util.assertNever(issue.validation); + } + } else if (issue.validation !== "regex") { + message = `Invalid ${issue.validation}`; + } else { + message = "Invalid"; + } break; case ZodIssueCode.too_small: if (issue.type === "array") diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index 89bd94773..66809c9ae 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -7,6 +7,8 @@ const minFive = z.string().min(5, "min5"); const maxFive = z.string().max(5, "max5"); const justFive = z.string().length(5); const nonempty = z.string().nonempty("nonempty"); +const startsWith = z.string().startsWith("startsWith"); +const endsWith = z.string().endsWith("endsWith"); test("passing validations", () => { minFive.parse("12345"); @@ -15,6 +17,8 @@ test("passing validations", () => { maxFive.parse("1234"); nonempty.parse("1"); justFive.parse("12345"); + startsWith.parse("startsWithX"); + endsWith.parse("XendsWith"); }); test("failing validations", () => { @@ -23,6 +27,8 @@ test("failing validations", () => { expect(() => nonempty.parse("")).toThrow(); expect(() => justFive.parse("1234")).toThrow(); expect(() => justFive.parse("123456")).toThrow(); + expect(() => startsWith.parse("x")).toThrow(); + expect(() => endsWith.parse("x")).toThrow(); }); test("email validations", () => { diff --git a/src/types.ts b/src/types.ts index bd5c130a5..5d78e224b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -450,6 +450,8 @@ type ZodStringCheck = | { kind: "url"; message?: string } | { kind: "uuid"; message?: string } | { kind: "cuid"; message?: string } + | { kind: "startsWith"; value: string; message?: string } + | { kind: "endsWith"; value: string; message?: string } | { kind: "regex"; regex: RegExp; message?: string } | { kind: "trim"; message?: string }; @@ -570,6 +572,26 @@ export class ZodString extends ZodType { } } else if (check.kind === "trim") { input.data = input.data.trim(); + } else if (check.kind === "startsWith") { + if (!(input.data as string).startsWith(check.value)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { startsWith: check.value }, + message: check.message, + }); + status.dirty(); + } + } else if (check.kind === "endsWith") { + if (!(input.data as string).endsWith(check.value)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { endsWith: check.value }, + message: check.message, + }); + status.dirty(); + } } else { util.assertNever(check); } @@ -616,6 +638,22 @@ export class ZodString extends ZodType { }); } + startsWith(value: string, message?: errorUtil.ErrMessage) { + return this._addCheck({ + kind: "startsWith", + value: value, + ...errorUtil.errToObj(message), + }); + } + + endsWith(value: string, message?: errorUtil.ErrMessage) { + return this._addCheck({ + kind: "endsWith", + value: value, + ...errorUtil.errToObj(message), + }); + } + min(minLength: number, message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "min",