From 9828837fb94f1500ef362b20ca5fe35eed1b6d0e Mon Sep 17 00:00:00 2001 From: John Schmitz Date: Sun, 11 Dec 2022 21:42:19 -0300 Subject: [PATCH] Fix issue #1611 (#1620) * Add exact length message for arrays * Add custom validation message for z.string().length() * Add exact flag to too_big and too_small Co-authored-by: Colin McDonnell --- deno/lib/ZodError.ts | 2 + deno/lib/__tests__/validations.test.ts | 36 ++++++++++++++ deno/lib/locales/en.ts | 44 ++++++++++------ deno/lib/types.ts | 69 +++++++++++++++++++++++++- src/ZodError.ts | 2 + src/__tests__/validations.test.ts | 36 ++++++++++++++ src/locales/en.ts | 44 ++++++++++------ src/types.ts | 69 +++++++++++++++++++++++++- 8 files changed, 270 insertions(+), 32 deletions(-) diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index 63b9af331..88d9db242 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -106,6 +106,7 @@ export interface ZodTooSmallIssue extends ZodIssueBase { code: typeof ZodIssueCode.too_small; minimum: number; inclusive: boolean; + exact: boolean; type: "array" | "string" | "number" | "set" | "date"; } @@ -113,6 +114,7 @@ export interface ZodTooBigIssue extends ZodIssueBase { code: typeof ZodIssueCode.too_big; maximum: number; inclusive: boolean; + exact: boolean; type: "array" | "string" | "number" | "set" | "date"; } diff --git a/deno/lib/__tests__/validations.test.ts b/deno/lib/__tests__/validations.test.ts index 5c58c875e..7ebbaa3d1 100644 --- a/deno/lib/__tests__/validations.test.ts +++ b/deno/lib/__tests__/validations.test.ts @@ -24,6 +24,42 @@ test("array max", async () => { } }); +test("array length", async () => { + try { + await z.array(z.string()).length(2).parseAsync(["asdf", "asdf", "asdf"]); + } catch (err) { + expect((err as z.ZodError).issues[0].message).toEqual( + "Array must contain exactly 2 element(s)" + ); + } + + try { + await z.array(z.string()).length(2).parseAsync(["asdf"]); + } catch (err) { + expect((err as z.ZodError).issues[0].message).toEqual( + "Array must contain exactly 2 element(s)" + ); + } +}); + +test("string length", async () => { + try { + await z.string().length(4).parseAsync("asd"); + } catch (err) { + expect((err as z.ZodError).issues[0].message).toEqual( + "String must contain exactly 4 character(s)" + ); + } + + try { + await z.string().length(4).parseAsync("asdaa"); + } catch (err) { + expect((err as z.ZodError).issues[0].message).toEqual( + "String must contain exactly 4 character(s)" + ); + } +}); + test("string min", async () => { try { await z.string().min(4).parseAsync("asd"); diff --git a/deno/lib/locales/en.ts b/deno/lib/locales/en.ts index 52685b003..348a84eeb 100644 --- a/deno/lib/locales/en.ts +++ b/deno/lib/locales/en.ts @@ -63,39 +63,55 @@ const errorMap: ZodErrorMap = (issue, _ctx) => { case ZodIssueCode.too_small: if (issue.type === "array") message = `Array must contain ${ - issue.inclusive ? `at least` : `more than` + issue.exact ? "exactly" : issue.inclusive ? `at least` : `more than` } ${issue.minimum} element(s)`; else if (issue.type === "string") message = `String must contain ${ - issue.inclusive ? `at least` : `over` + issue.exact ? "exactly" : issue.inclusive ? `at least` : `over` } ${issue.minimum} character(s)`; else if (issue.type === "number") - message = `Number must be greater than ${ - issue.inclusive ? `or equal to ` : `` + message = `Number must be ${ + issue.exact + ? `exactly equal to ` + : issue.inclusive + ? `greater than or equal to ` + : `greater than ` }${issue.minimum}`; else if (issue.type === "date") - message = `Date must be greater than ${ - issue.inclusive ? `or equal to ` : `` + message = `Date must be ${ + issue.exact + ? `exactly equal to ` + : issue.inclusive + ? `greater than or equal to ` + : `greater than ` }${new Date(issue.minimum)}`; else message = "Invalid input"; break; case ZodIssueCode.too_big: if (issue.type === "array") message = `Array must contain ${ - issue.inclusive ? `at most` : `less than` + issue.exact ? `exactly` : issue.inclusive ? `at most` : `less than` } ${issue.maximum} element(s)`; else if (issue.type === "string") message = `String must contain ${ - issue.inclusive ? `at most` : `under` + issue.exact ? `exactly` : issue.inclusive ? `at most` : `under` } ${issue.maximum} character(s)`; else if (issue.type === "number") - message = `Number must be less than ${ - issue.inclusive ? `or equal to ` : `` - }${issue.maximum}`; + message = `Number must be ${ + issue.exact + ? `exactly` + : issue.inclusive + ? `less than or equal to` + : `less than` + } ${issue.maximum}`; else if (issue.type === "date") - message = `Date must be smaller than ${ - issue.inclusive ? `or equal to ` : `` - }${new Date(issue.maximum)}`; + message = `Date must be ${ + issue.exact + ? `exactly` + : issue.inclusive + ? `smaller than or equal to` + : `smaller than` + } ${new Date(issue.maximum)}`; else message = "Invalid input"; break; case ZodIssueCode.custom: diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 7385a40ad..533da2265 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -485,6 +485,7 @@ export abstract class ZodType< export type ZodStringCheck = | { kind: "min"; value: number; message?: string } | { kind: "max"; value: number; message?: string } + | { kind: "length"; value: number; message?: string } | { kind: "email"; message?: string } | { kind: "url"; message?: string } | { kind: "uuid"; message?: string } @@ -589,6 +590,7 @@ export class ZodString extends ZodType { minimum: check.value, type: "string", inclusive: true, + exact: false, message: check.message, }); status.dirty(); @@ -601,10 +603,37 @@ export class ZodString extends ZodType { maximum: check.value, type: "string", inclusive: true, + exact: false, message: check.message, }); status.dirty(); } + } else if (check.kind === "length") { + const tooBig = input.data.length > check.value; + const tooSmall = input.data.length < check.value; + if (tooBig || tooSmall) { + ctx = this._getOrReturnCtx(input, ctx); + if (tooBig) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: check.value, + type: "string", + inclusive: true, + exact: true, + message: check.message, + }); + } else if (tooSmall) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: check.value, + type: "string", + inclusive: true, + exact: true, + message: check.message, + }); + } + status.dirty(); + } } else if (check.kind === "email") { if (!emailRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); @@ -798,7 +827,11 @@ export class ZodString extends ZodType { } length(len: number, message?: errorUtil.ErrMessage) { - return this.min(len, message).max(len, message); + return this._addCheck({ + kind: "length", + value: len, + ...errorUtil.errToObj(message), + }); } /** @@ -932,6 +965,7 @@ export class ZodNumber extends ZodType { minimum: check.value, type: "number", inclusive: check.inclusive, + exact: false, message: check.message, }); status.dirty(); @@ -947,6 +981,7 @@ export class ZodNumber extends ZodType { maximum: check.value, type: "number", inclusive: check.inclusive, + exact: false, message: check.message, }); status.dirty(); @@ -1255,6 +1290,7 @@ export class ZodDate extends ZodType { code: ZodIssueCode.too_small, message: check.message, inclusive: true, + exact: false, minimum: check.value, type: "date", }); @@ -1267,6 +1303,7 @@ export class ZodDate extends ZodType { code: ZodIssueCode.too_big, message: check.message, inclusive: true, + exact: false, maximum: check.value, type: "date", }); @@ -1568,6 +1605,7 @@ export interface ZodArrayDef extends ZodTypeDef { type: T; typeName: ZodFirstPartyTypeKind.ZodArray; + exactLength: { value: number; message?: string } | null; minLength: { value: number; message?: string } | null; maxLength: { value: number; message?: string } | null; } @@ -1604,6 +1642,23 @@ export class ZodArray< return INVALID; } + if (def.exactLength !== null) { + const tooBig = ctx.data.length > def.exactLength.value; + const tooSmall = ctx.data.length < def.exactLength.value; + if (tooBig || tooSmall) { + addIssueToContext(ctx, { + code: tooBig ? ZodIssueCode.too_big : ZodIssueCode.too_small, + minimum: (tooSmall ? def.exactLength.value : undefined) as number, + maximum: (tooBig ? def.exactLength.value : undefined) as number, + type: "array", + inclusive: true, + exact: true, + message: def.exactLength.message, + }); + status.dirty(); + } + } + if (def.minLength !== null) { if (ctx.data.length < def.minLength.value) { addIssueToContext(ctx, { @@ -1611,6 +1666,7 @@ export class ZodArray< minimum: def.minLength.value, type: "array", inclusive: true, + exact: false, message: def.minLength.message, }); status.dirty(); @@ -1624,6 +1680,7 @@ export class ZodArray< maximum: def.maxLength.value, type: "array", inclusive: true, + exact: false, message: def.maxLength.message, }); status.dirty(); @@ -1670,7 +1727,10 @@ export class ZodArray< } length(len: number, message?: errorUtil.ErrMessage): this { - return this.min(len, message).max(len, message) as any; + return new ZodArray({ + ...this._def, + exactLength: { value: len, message: errorUtil.toString(message) }, + }) as any; } nonempty(message?: errorUtil.ErrMessage): ZodArray { @@ -1685,6 +1745,7 @@ export class ZodArray< type: schema, minLength: null, maxLength: null, + exactLength: null, typeName: ZodFirstPartyTypeKind.ZodArray, ...processCreateParams(params), }); @@ -2747,6 +2808,7 @@ export class ZodTuple< code: ZodIssueCode.too_small, minimum: this._def.items.length, inclusive: true, + exact: false, type: "array", }); @@ -2760,6 +2822,7 @@ export class ZodTuple< code: ZodIssueCode.too_big, maximum: this._def.items.length, inclusive: true, + exact: false, type: "array", }); status.dirty(); @@ -3060,6 +3123,7 @@ export class ZodSet extends ZodType< minimum: def.minSize.value, type: "set", inclusive: true, + exact: false, message: def.minSize.message, }); status.dirty(); @@ -3073,6 +3137,7 @@ export class ZodSet extends ZodType< maximum: def.maxSize.value, type: "set", inclusive: true, + exact: false, message: def.maxSize.message, }); status.dirty(); diff --git a/src/ZodError.ts b/src/ZodError.ts index d823e2b0d..323c420d0 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -106,6 +106,7 @@ export interface ZodTooSmallIssue extends ZodIssueBase { code: typeof ZodIssueCode.too_small; minimum: number; inclusive: boolean; + exact: boolean; type: "array" | "string" | "number" | "set" | "date"; } @@ -113,6 +114,7 @@ export interface ZodTooBigIssue extends ZodIssueBase { code: typeof ZodIssueCode.too_big; maximum: number; inclusive: boolean; + exact: boolean; type: "array" | "string" | "number" | "set" | "date"; } diff --git a/src/__tests__/validations.test.ts b/src/__tests__/validations.test.ts index 3da3466fc..6ab351b20 100644 --- a/src/__tests__/validations.test.ts +++ b/src/__tests__/validations.test.ts @@ -23,6 +23,42 @@ test("array max", async () => { } }); +test("array length", async () => { + try { + await z.array(z.string()).length(2).parseAsync(["asdf", "asdf", "asdf"]); + } catch (err) { + expect((err as z.ZodError).issues[0].message).toEqual( + "Array must contain exactly 2 element(s)" + ); + } + + try { + await z.array(z.string()).length(2).parseAsync(["asdf"]); + } catch (err) { + expect((err as z.ZodError).issues[0].message).toEqual( + "Array must contain exactly 2 element(s)" + ); + } +}); + +test("string length", async () => { + try { + await z.string().length(4).parseAsync("asd"); + } catch (err) { + expect((err as z.ZodError).issues[0].message).toEqual( + "String must contain exactly 4 character(s)" + ); + } + + try { + await z.string().length(4).parseAsync("asdaa"); + } catch (err) { + expect((err as z.ZodError).issues[0].message).toEqual( + "String must contain exactly 4 character(s)" + ); + } +}); + test("string min", async () => { try { await z.string().min(4).parseAsync("asd"); diff --git a/src/locales/en.ts b/src/locales/en.ts index 98977aa63..6515f9849 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -63,39 +63,55 @@ const errorMap: ZodErrorMap = (issue, _ctx) => { case ZodIssueCode.too_small: if (issue.type === "array") message = `Array must contain ${ - issue.inclusive ? `at least` : `more than` + issue.exact ? "exactly" : issue.inclusive ? `at least` : `more than` } ${issue.minimum} element(s)`; else if (issue.type === "string") message = `String must contain ${ - issue.inclusive ? `at least` : `over` + issue.exact ? "exactly" : issue.inclusive ? `at least` : `over` } ${issue.minimum} character(s)`; else if (issue.type === "number") - message = `Number must be greater than ${ - issue.inclusive ? `or equal to ` : `` + message = `Number must be ${ + issue.exact + ? `exactly equal to ` + : issue.inclusive + ? `greater than or equal to ` + : `greater than ` }${issue.minimum}`; else if (issue.type === "date") - message = `Date must be greater than ${ - issue.inclusive ? `or equal to ` : `` + message = `Date must be ${ + issue.exact + ? `exactly equal to ` + : issue.inclusive + ? `greater than or equal to ` + : `greater than ` }${new Date(issue.minimum)}`; else message = "Invalid input"; break; case ZodIssueCode.too_big: if (issue.type === "array") message = `Array must contain ${ - issue.inclusive ? `at most` : `less than` + issue.exact ? `exactly` : issue.inclusive ? `at most` : `less than` } ${issue.maximum} element(s)`; else if (issue.type === "string") message = `String must contain ${ - issue.inclusive ? `at most` : `under` + issue.exact ? `exactly` : issue.inclusive ? `at most` : `under` } ${issue.maximum} character(s)`; else if (issue.type === "number") - message = `Number must be less than ${ - issue.inclusive ? `or equal to ` : `` - }${issue.maximum}`; + message = `Number must be ${ + issue.exact + ? `exactly` + : issue.inclusive + ? `less than or equal to` + : `less than` + } ${issue.maximum}`; else if (issue.type === "date") - message = `Date must be smaller than ${ - issue.inclusive ? `or equal to ` : `` - }${new Date(issue.maximum)}`; + message = `Date must be ${ + issue.exact + ? `exactly` + : issue.inclusive + ? `smaller than or equal to` + : `smaller than` + } ${new Date(issue.maximum)}`; else message = "Invalid input"; break; case ZodIssueCode.custom: diff --git a/src/types.ts b/src/types.ts index 7b16fb5f9..f03d62625 100644 --- a/src/types.ts +++ b/src/types.ts @@ -485,6 +485,7 @@ export abstract class ZodType< export type ZodStringCheck = | { kind: "min"; value: number; message?: string } | { kind: "max"; value: number; message?: string } + | { kind: "length"; value: number; message?: string } | { kind: "email"; message?: string } | { kind: "url"; message?: string } | { kind: "uuid"; message?: string } @@ -589,6 +590,7 @@ export class ZodString extends ZodType { minimum: check.value, type: "string", inclusive: true, + exact: false, message: check.message, }); status.dirty(); @@ -601,10 +603,37 @@ export class ZodString extends ZodType { maximum: check.value, type: "string", inclusive: true, + exact: false, message: check.message, }); status.dirty(); } + } else if (check.kind === "length") { + const tooBig = input.data.length > check.value; + const tooSmall = input.data.length < check.value; + if (tooBig || tooSmall) { + ctx = this._getOrReturnCtx(input, ctx); + if (tooBig) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: check.value, + type: "string", + inclusive: true, + exact: true, + message: check.message, + }); + } else if (tooSmall) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: check.value, + type: "string", + inclusive: true, + exact: true, + message: check.message, + }); + } + status.dirty(); + } } else if (check.kind === "email") { if (!emailRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); @@ -798,7 +827,11 @@ export class ZodString extends ZodType { } length(len: number, message?: errorUtil.ErrMessage) { - return this.min(len, message).max(len, message); + return this._addCheck({ + kind: "length", + value: len, + ...errorUtil.errToObj(message), + }); } /** @@ -932,6 +965,7 @@ export class ZodNumber extends ZodType { minimum: check.value, type: "number", inclusive: check.inclusive, + exact: false, message: check.message, }); status.dirty(); @@ -947,6 +981,7 @@ export class ZodNumber extends ZodType { maximum: check.value, type: "number", inclusive: check.inclusive, + exact: false, message: check.message, }); status.dirty(); @@ -1255,6 +1290,7 @@ export class ZodDate extends ZodType { code: ZodIssueCode.too_small, message: check.message, inclusive: true, + exact: false, minimum: check.value, type: "date", }); @@ -1267,6 +1303,7 @@ export class ZodDate extends ZodType { code: ZodIssueCode.too_big, message: check.message, inclusive: true, + exact: false, maximum: check.value, type: "date", }); @@ -1568,6 +1605,7 @@ export interface ZodArrayDef extends ZodTypeDef { type: T; typeName: ZodFirstPartyTypeKind.ZodArray; + exactLength: { value: number; message?: string } | null; minLength: { value: number; message?: string } | null; maxLength: { value: number; message?: string } | null; } @@ -1604,6 +1642,23 @@ export class ZodArray< return INVALID; } + if (def.exactLength !== null) { + const tooBig = ctx.data.length > def.exactLength.value; + const tooSmall = ctx.data.length < def.exactLength.value; + if (tooBig || tooSmall) { + addIssueToContext(ctx, { + code: tooBig ? ZodIssueCode.too_big : ZodIssueCode.too_small, + minimum: (tooSmall ? def.exactLength.value : undefined) as number, + maximum: (tooBig ? def.exactLength.value : undefined) as number, + type: "array", + inclusive: true, + exact: true, + message: def.exactLength.message, + }); + status.dirty(); + } + } + if (def.minLength !== null) { if (ctx.data.length < def.minLength.value) { addIssueToContext(ctx, { @@ -1611,6 +1666,7 @@ export class ZodArray< minimum: def.minLength.value, type: "array", inclusive: true, + exact: false, message: def.minLength.message, }); status.dirty(); @@ -1624,6 +1680,7 @@ export class ZodArray< maximum: def.maxLength.value, type: "array", inclusive: true, + exact: false, message: def.maxLength.message, }); status.dirty(); @@ -1670,7 +1727,10 @@ export class ZodArray< } length(len: number, message?: errorUtil.ErrMessage): this { - return this.min(len, message).max(len, message) as any; + return new ZodArray({ + ...this._def, + exactLength: { value: len, message: errorUtil.toString(message) }, + }) as any; } nonempty(message?: errorUtil.ErrMessage): ZodArray { @@ -1685,6 +1745,7 @@ export class ZodArray< type: schema, minLength: null, maxLength: null, + exactLength: null, typeName: ZodFirstPartyTypeKind.ZodArray, ...processCreateParams(params), }); @@ -2747,6 +2808,7 @@ export class ZodTuple< code: ZodIssueCode.too_small, minimum: this._def.items.length, inclusive: true, + exact: false, type: "array", }); @@ -2760,6 +2822,7 @@ export class ZodTuple< code: ZodIssueCode.too_big, maximum: this._def.items.length, inclusive: true, + exact: false, type: "array", }); status.dirty(); @@ -3060,6 +3123,7 @@ export class ZodSet extends ZodType< minimum: def.minSize.value, type: "set", inclusive: true, + exact: false, message: def.minSize.message, }); status.dirty(); @@ -3073,6 +3137,7 @@ export class ZodSet extends ZodType< maximum: def.maxSize.value, type: "set", inclusive: true, + exact: false, message: def.maxSize.message, }); status.dirty();