diff --git a/README.md b/README.md index 53368dd49..579cf590e 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,7 @@ z.string().uuid(); z.string().cuid(); z.string().cuid2(); z.string().regex(regex); +z.string().includes(string); z.string().startsWith(string); z.string().endsWith(string); z.string().trim(); // trim whitespace @@ -626,6 +627,7 @@ z.string().email({ message: "Invalid email address" }); z.string().url({ message: "Invalid url" }); z.string().emoji({ message: "Contains non-emoji characters" }); z.string().uuid({ message: "Invalid UUID" }); +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." }); diff --git a/deno/lib/README.md b/deno/lib/README.md index 439cfecd2..373816052 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -594,6 +594,7 @@ z.string().uuid(); z.string().cuid(); z.string().cuid2(); z.string().regex(regex); +z.string().includes(string); z.string().startsWith(string); z.string().endsWith(string); z.string().trim(); // trim whitespace @@ -624,6 +625,7 @@ z.string().email({ message: "Invalid email address" }); z.string().url({ message: "Invalid url" }); z.string().emoji({ message: "Contains non-emoji characters" }); z.string().uuid({ message: "Invalid UUID" }); +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." }); diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index def27ddfc..c1b9c6fc0 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -98,6 +98,7 @@ export type StringValidation = | "cuid2" | "datetime" | "ip" + | { includes: string; position?: number } | { startsWith: string } | { endsWith: string }; diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 4e1e5a5bb..f76d98479 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -7,7 +7,9 @@ import * as z from "../index.ts"; 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 nonempty = z.string().min(1, "nonempty"); +const includes = z.string().includes("includes"); +const includesFromIndex2 = z.string().includes("includes", { position: 2 }); const startsWith = z.string().startsWith("startsWith"); const endsWith = z.string().endsWith("endsWith"); @@ -18,6 +20,8 @@ test("passing validations", () => { maxFive.parse("1234"); nonempty.parse("1"); justFive.parse("12345"); + includes.parse("XincludesXX"); + includesFromIndex2.parse("XXXincludesXX"); startsWith.parse("startsWithX"); endsWith.parse("XendsWith"); }); @@ -28,6 +32,8 @@ test("failing validations", () => { expect(() => nonempty.parse("")).toThrow(); expect(() => justFive.parse("1234")).toThrow(); expect(() => justFive.parse("123456")).toThrow(); + expect(() => includes.parse("XincludeXX")).toThrow(); + expect(() => includesFromIndex2.parse("XincludesXX")).toThrow(); expect(() => startsWith.parse("x")).toThrow(); expect(() => endsWith.parse("x")).toThrow(); }); diff --git a/deno/lib/locales/en.ts b/deno/lib/locales/en.ts index 30e564ce0..0665af275 100644 --- a/deno/lib/locales/en.ts +++ b/deno/lib/locales/en.ts @@ -47,7 +47,13 @@ const errorMap: ZodErrorMap = (issue, _ctx) => { break; case ZodIssueCode.invalid_string: if (typeof issue.validation === "object") { - if ("startsWith" in issue.validation) { + if ("includes" in issue.validation) { + message = `Invalid input: must include "${issue.validation.includes}"`; + + if (typeof issue.validation.position === "number") { + message = `${message} at one or more positions greater than or equal to ${issue.validation.position}`; + } + } else if ("startsWith" in issue.validation) { message = `Invalid input: must start with "${issue.validation.startsWith}"`; } else if ("endsWith" in issue.validation) { message = `Invalid input: must end with "${issue.validation.endsWith}"`; diff --git a/deno/lib/types.ts b/deno/lib/types.ts index da20a76c0..23d8f14e0 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -496,6 +496,7 @@ export type ZodStringCheck = | { kind: "emoji"; message?: string } | { kind: "uuid"; message?: string } | { kind: "cuid"; message?: string } + | { kind: "includes"; value: string; position?: number; message?: string } | { kind: "cuid2"; message?: string } | { kind: "startsWith"; value: string; message?: string } | { kind: "endsWith"; value: string; message?: string } @@ -736,6 +737,16 @@ export class ZodString extends ZodType { } } else if (check.kind === "trim") { input.data = input.data.trim(); + } else if (check.kind === "includes") { + if (!(input.data as string).includes(check.value, check.position)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { includes: check.value, position: check.position }, + message: check.message, + }); + status.dirty(); + } } else if (check.kind === "toLowerCase") { input.data = input.data.toLowerCase(); } else if (check.kind === "toUpperCase") { @@ -865,6 +876,15 @@ export class ZodString extends ZodType { }); } + includes(value: string, options?: { message?: string; position?: number }) { + return this._addCheck({ + kind: "includes", + value: value, + position: options?.position, + ...errorUtil.errToObj(options?.message), + }); + } + startsWith(value: string, message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "startsWith", diff --git a/src/ZodError.ts b/src/ZodError.ts index 0802c1793..f76acb10c 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -98,6 +98,7 @@ export type StringValidation = | "cuid2" | "datetime" | "ip" + | { includes: string; position?: number } | { startsWith: string } | { endsWith: string }; diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index fa55796ca..132c9a9fa 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -6,7 +6,9 @@ import * as z from "../index"; 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 nonempty = z.string().min(1, "nonempty"); +const includes = z.string().includes("includes"); +const includesFromIndex2 = z.string().includes("includes", { position: 2 }); const startsWith = z.string().startsWith("startsWith"); const endsWith = z.string().endsWith("endsWith"); @@ -17,6 +19,8 @@ test("passing validations", () => { maxFive.parse("1234"); nonempty.parse("1"); justFive.parse("12345"); + includes.parse("XincludesXX"); + includesFromIndex2.parse("XXXincludesXX"); startsWith.parse("startsWithX"); endsWith.parse("XendsWith"); }); @@ -27,6 +31,8 @@ test("failing validations", () => { expect(() => nonempty.parse("")).toThrow(); expect(() => justFive.parse("1234")).toThrow(); expect(() => justFive.parse("123456")).toThrow(); + expect(() => includes.parse("XincludeXX")).toThrow(); + expect(() => includesFromIndex2.parse("XincludesXX")).toThrow(); expect(() => startsWith.parse("x")).toThrow(); expect(() => endsWith.parse("x")).toThrow(); }); diff --git a/src/locales/en.ts b/src/locales/en.ts index f09f48735..11325a95b 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -47,7 +47,13 @@ const errorMap: ZodErrorMap = (issue, _ctx) => { break; case ZodIssueCode.invalid_string: if (typeof issue.validation === "object") { - if ("startsWith" in issue.validation) { + if ("includes" in issue.validation) { + message = `Invalid input: must include "${issue.validation.includes}"`; + + if (typeof issue.validation.position === "number") { + message = `${message} at one or more positions greater than or equal to ${issue.validation.position}`; + } + } else if ("startsWith" in issue.validation) { message = `Invalid input: must start with "${issue.validation.startsWith}"`; } else if ("endsWith" in issue.validation) { message = `Invalid input: must end with "${issue.validation.endsWith}"`; diff --git a/src/types.ts b/src/types.ts index cd01a65e3..4b0051ee3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -496,6 +496,7 @@ export type ZodStringCheck = | { kind: "emoji"; message?: string } | { kind: "uuid"; message?: string } | { kind: "cuid"; message?: string } + | { kind: "includes"; value: string; position?: number; message?: string } | { kind: "cuid2"; message?: string } | { kind: "startsWith"; value: string; message?: string } | { kind: "endsWith"; value: string; message?: string } @@ -736,6 +737,16 @@ export class ZodString extends ZodType { } } else if (check.kind === "trim") { input.data = input.data.trim(); + } else if (check.kind === "includes") { + if (!(input.data as string).includes(check.value, check.position)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { includes: check.value, position: check.position }, + message: check.message, + }); + status.dirty(); + } } else if (check.kind === "toLowerCase") { input.data = input.data.toLowerCase(); } else if (check.kind === "toUpperCase") { @@ -865,6 +876,15 @@ export class ZodString extends ZodType { }); } + includes(value: string, options?: { message?: string; position?: number }) { + return this._addCheck({ + kind: "includes", + value: value, + position: options?.position, + ...errorUtil.errToObj(options?.message), + }); + } + startsWith(value: string, message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "startsWith",