Skip to content

Commit 9828837

Browse files
john-schmitzColin McDonnell
andauthored
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 <[email protected]>
1 parent 497d44b commit 9828837

File tree

8 files changed

+270
-32
lines changed

8 files changed

+270
-32
lines changed

deno/lib/ZodError.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,15 @@ export interface ZodTooSmallIssue extends ZodIssueBase {
106106
code: typeof ZodIssueCode.too_small;
107107
minimum: number;
108108
inclusive: boolean;
109+
exact: boolean;
109110
type: "array" | "string" | "number" | "set" | "date";
110111
}
111112

112113
export interface ZodTooBigIssue extends ZodIssueBase {
113114
code: typeof ZodIssueCode.too_big;
114115
maximum: number;
115116
inclusive: boolean;
117+
exact: boolean;
116118
type: "array" | "string" | "number" | "set" | "date";
117119
}
118120

deno/lib/__tests__/validations.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,42 @@ test("array max", async () => {
2424
}
2525
});
2626

27+
test("array length", async () => {
28+
try {
29+
await z.array(z.string()).length(2).parseAsync(["asdf", "asdf", "asdf"]);
30+
} catch (err) {
31+
expect((err as z.ZodError).issues[0].message).toEqual(
32+
"Array must contain exactly 2 element(s)"
33+
);
34+
}
35+
36+
try {
37+
await z.array(z.string()).length(2).parseAsync(["asdf"]);
38+
} catch (err) {
39+
expect((err as z.ZodError).issues[0].message).toEqual(
40+
"Array must contain exactly 2 element(s)"
41+
);
42+
}
43+
});
44+
45+
test("string length", async () => {
46+
try {
47+
await z.string().length(4).parseAsync("asd");
48+
} catch (err) {
49+
expect((err as z.ZodError).issues[0].message).toEqual(
50+
"String must contain exactly 4 character(s)"
51+
);
52+
}
53+
54+
try {
55+
await z.string().length(4).parseAsync("asdaa");
56+
} catch (err) {
57+
expect((err as z.ZodError).issues[0].message).toEqual(
58+
"String must contain exactly 4 character(s)"
59+
);
60+
}
61+
});
62+
2763
test("string min", async () => {
2864
try {
2965
await z.string().min(4).parseAsync("asd");

deno/lib/locales/en.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,39 +63,55 @@ const errorMap: ZodErrorMap = (issue, _ctx) => {
6363
case ZodIssueCode.too_small:
6464
if (issue.type === "array")
6565
message = `Array must contain ${
66-
issue.inclusive ? `at least` : `more than`
66+
issue.exact ? "exactly" : issue.inclusive ? `at least` : `more than`
6767
} ${issue.minimum} element(s)`;
6868
else if (issue.type === "string")
6969
message = `String must contain ${
70-
issue.inclusive ? `at least` : `over`
70+
issue.exact ? "exactly" : issue.inclusive ? `at least` : `over`
7171
} ${issue.minimum} character(s)`;
7272
else if (issue.type === "number")
73-
message = `Number must be greater than ${
74-
issue.inclusive ? `or equal to ` : ``
73+
message = `Number must be ${
74+
issue.exact
75+
? `exactly equal to `
76+
: issue.inclusive
77+
? `greater than or equal to `
78+
: `greater than `
7579
}${issue.minimum}`;
7680
else if (issue.type === "date")
77-
message = `Date must be greater than ${
78-
issue.inclusive ? `or equal to ` : ``
81+
message = `Date must be ${
82+
issue.exact
83+
? `exactly equal to `
84+
: issue.inclusive
85+
? `greater than or equal to `
86+
: `greater than `
7987
}${new Date(issue.minimum)}`;
8088
else message = "Invalid input";
8189
break;
8290
case ZodIssueCode.too_big:
8391
if (issue.type === "array")
8492
message = `Array must contain ${
85-
issue.inclusive ? `at most` : `less than`
93+
issue.exact ? `exactly` : issue.inclusive ? `at most` : `less than`
8694
} ${issue.maximum} element(s)`;
8795
else if (issue.type === "string")
8896
message = `String must contain ${
89-
issue.inclusive ? `at most` : `under`
97+
issue.exact ? `exactly` : issue.inclusive ? `at most` : `under`
9098
} ${issue.maximum} character(s)`;
9199
else if (issue.type === "number")
92-
message = `Number must be less than ${
93-
issue.inclusive ? `or equal to ` : ``
94-
}${issue.maximum}`;
100+
message = `Number must be ${
101+
issue.exact
102+
? `exactly`
103+
: issue.inclusive
104+
? `less than or equal to`
105+
: `less than`
106+
} ${issue.maximum}`;
95107
else if (issue.type === "date")
96-
message = `Date must be smaller than ${
97-
issue.inclusive ? `or equal to ` : ``
98-
}${new Date(issue.maximum)}`;
108+
message = `Date must be ${
109+
issue.exact
110+
? `exactly`
111+
: issue.inclusive
112+
? `smaller than or equal to`
113+
: `smaller than`
114+
} ${new Date(issue.maximum)}`;
99115
else message = "Invalid input";
100116
break;
101117
case ZodIssueCode.custom:

deno/lib/types.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ export abstract class ZodType<
485485
export type ZodStringCheck =
486486
| { kind: "min"; value: number; message?: string }
487487
| { kind: "max"; value: number; message?: string }
488+
| { kind: "length"; value: number; message?: string }
488489
| { kind: "email"; message?: string }
489490
| { kind: "url"; message?: string }
490491
| { kind: "uuid"; message?: string }
@@ -589,6 +590,7 @@ export class ZodString extends ZodType<string, ZodStringDef> {
589590
minimum: check.value,
590591
type: "string",
591592
inclusive: true,
593+
exact: false,
592594
message: check.message,
593595
});
594596
status.dirty();
@@ -601,10 +603,37 @@ export class ZodString extends ZodType<string, ZodStringDef> {
601603
maximum: check.value,
602604
type: "string",
603605
inclusive: true,
606+
exact: false,
604607
message: check.message,
605608
});
606609
status.dirty();
607610
}
611+
} else if (check.kind === "length") {
612+
const tooBig = input.data.length > check.value;
613+
const tooSmall = input.data.length < check.value;
614+
if (tooBig || tooSmall) {
615+
ctx = this._getOrReturnCtx(input, ctx);
616+
if (tooBig) {
617+
addIssueToContext(ctx, {
618+
code: ZodIssueCode.too_big,
619+
maximum: check.value,
620+
type: "string",
621+
inclusive: true,
622+
exact: true,
623+
message: check.message,
624+
});
625+
} else if (tooSmall) {
626+
addIssueToContext(ctx, {
627+
code: ZodIssueCode.too_small,
628+
minimum: check.value,
629+
type: "string",
630+
inclusive: true,
631+
exact: true,
632+
message: check.message,
633+
});
634+
}
635+
status.dirty();
636+
}
608637
} else if (check.kind === "email") {
609638
if (!emailRegex.test(input.data)) {
610639
ctx = this._getOrReturnCtx(input, ctx);
@@ -798,7 +827,11 @@ export class ZodString extends ZodType<string, ZodStringDef> {
798827
}
799828

800829
length(len: number, message?: errorUtil.ErrMessage) {
801-
return this.min(len, message).max(len, message);
830+
return this._addCheck({
831+
kind: "length",
832+
value: len,
833+
...errorUtil.errToObj(message),
834+
});
802835
}
803836

804837
/**
@@ -932,6 +965,7 @@ export class ZodNumber extends ZodType<number, ZodNumberDef> {
932965
minimum: check.value,
933966
type: "number",
934967
inclusive: check.inclusive,
968+
exact: false,
935969
message: check.message,
936970
});
937971
status.dirty();
@@ -947,6 +981,7 @@ export class ZodNumber extends ZodType<number, ZodNumberDef> {
947981
maximum: check.value,
948982
type: "number",
949983
inclusive: check.inclusive,
984+
exact: false,
950985
message: check.message,
951986
});
952987
status.dirty();
@@ -1255,6 +1290,7 @@ export class ZodDate extends ZodType<Date, ZodDateDef> {
12551290
code: ZodIssueCode.too_small,
12561291
message: check.message,
12571292
inclusive: true,
1293+
exact: false,
12581294
minimum: check.value,
12591295
type: "date",
12601296
});
@@ -1267,6 +1303,7 @@ export class ZodDate extends ZodType<Date, ZodDateDef> {
12671303
code: ZodIssueCode.too_big,
12681304
message: check.message,
12691305
inclusive: true,
1306+
exact: false,
12701307
maximum: check.value,
12711308
type: "date",
12721309
});
@@ -1568,6 +1605,7 @@ export interface ZodArrayDef<T extends ZodTypeAny = ZodTypeAny>
15681605
extends ZodTypeDef {
15691606
type: T;
15701607
typeName: ZodFirstPartyTypeKind.ZodArray;
1608+
exactLength: { value: number; message?: string } | null;
15711609
minLength: { value: number; message?: string } | null;
15721610
maxLength: { value: number; message?: string } | null;
15731611
}
@@ -1604,13 +1642,31 @@ export class ZodArray<
16041642
return INVALID;
16051643
}
16061644

1645+
if (def.exactLength !== null) {
1646+
const tooBig = ctx.data.length > def.exactLength.value;
1647+
const tooSmall = ctx.data.length < def.exactLength.value;
1648+
if (tooBig || tooSmall) {
1649+
addIssueToContext(ctx, {
1650+
code: tooBig ? ZodIssueCode.too_big : ZodIssueCode.too_small,
1651+
minimum: (tooSmall ? def.exactLength.value : undefined) as number,
1652+
maximum: (tooBig ? def.exactLength.value : undefined) as number,
1653+
type: "array",
1654+
inclusive: true,
1655+
exact: true,
1656+
message: def.exactLength.message,
1657+
});
1658+
status.dirty();
1659+
}
1660+
}
1661+
16071662
if (def.minLength !== null) {
16081663
if (ctx.data.length < def.minLength.value) {
16091664
addIssueToContext(ctx, {
16101665
code: ZodIssueCode.too_small,
16111666
minimum: def.minLength.value,
16121667
type: "array",
16131668
inclusive: true,
1669+
exact: false,
16141670
message: def.minLength.message,
16151671
});
16161672
status.dirty();
@@ -1624,6 +1680,7 @@ export class ZodArray<
16241680
maximum: def.maxLength.value,
16251681
type: "array",
16261682
inclusive: true,
1683+
exact: false,
16271684
message: def.maxLength.message,
16281685
});
16291686
status.dirty();
@@ -1670,7 +1727,10 @@ export class ZodArray<
16701727
}
16711728

16721729
length(len: number, message?: errorUtil.ErrMessage): this {
1673-
return this.min(len, message).max(len, message) as any;
1730+
return new ZodArray({
1731+
...this._def,
1732+
exactLength: { value: len, message: errorUtil.toString(message) },
1733+
}) as any;
16741734
}
16751735

16761736
nonempty(message?: errorUtil.ErrMessage): ZodArray<T, "atleastone"> {
@@ -1685,6 +1745,7 @@ export class ZodArray<
16851745
type: schema,
16861746
minLength: null,
16871747
maxLength: null,
1748+
exactLength: null,
16881749
typeName: ZodFirstPartyTypeKind.ZodArray,
16891750
...processCreateParams(params),
16901751
});
@@ -2747,6 +2808,7 @@ export class ZodTuple<
27472808
code: ZodIssueCode.too_small,
27482809
minimum: this._def.items.length,
27492810
inclusive: true,
2811+
exact: false,
27502812
type: "array",
27512813
});
27522814

@@ -2760,6 +2822,7 @@ export class ZodTuple<
27602822
code: ZodIssueCode.too_big,
27612823
maximum: this._def.items.length,
27622824
inclusive: true,
2825+
exact: false,
27632826
type: "array",
27642827
});
27652828
status.dirty();
@@ -3060,6 +3123,7 @@ export class ZodSet<Value extends ZodTypeAny = ZodTypeAny> extends ZodType<
30603123
minimum: def.minSize.value,
30613124
type: "set",
30623125
inclusive: true,
3126+
exact: false,
30633127
message: def.minSize.message,
30643128
});
30653129
status.dirty();
@@ -3073,6 +3137,7 @@ export class ZodSet<Value extends ZodTypeAny = ZodTypeAny> extends ZodType<
30733137
maximum: def.maxSize.value,
30743138
type: "set",
30753139
inclusive: true,
3140+
exact: false,
30763141
message: def.maxSize.message,
30773142
});
30783143
status.dirty();

src/ZodError.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,15 @@ export interface ZodTooSmallIssue extends ZodIssueBase {
106106
code: typeof ZodIssueCode.too_small;
107107
minimum: number;
108108
inclusive: boolean;
109+
exact: boolean;
109110
type: "array" | "string" | "number" | "set" | "date";
110111
}
111112

112113
export interface ZodTooBigIssue extends ZodIssueBase {
113114
code: typeof ZodIssueCode.too_big;
114115
maximum: number;
115116
inclusive: boolean;
117+
exact: boolean;
116118
type: "array" | "string" | "number" | "set" | "date";
117119
}
118120

src/__tests__/validations.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,42 @@ test("array max", async () => {
2323
}
2424
});
2525

26+
test("array length", async () => {
27+
try {
28+
await z.array(z.string()).length(2).parseAsync(["asdf", "asdf", "asdf"]);
29+
} catch (err) {
30+
expect((err as z.ZodError).issues[0].message).toEqual(
31+
"Array must contain exactly 2 element(s)"
32+
);
33+
}
34+
35+
try {
36+
await z.array(z.string()).length(2).parseAsync(["asdf"]);
37+
} catch (err) {
38+
expect((err as z.ZodError).issues[0].message).toEqual(
39+
"Array must contain exactly 2 element(s)"
40+
);
41+
}
42+
});
43+
44+
test("string length", async () => {
45+
try {
46+
await z.string().length(4).parseAsync("asd");
47+
} catch (err) {
48+
expect((err as z.ZodError).issues[0].message).toEqual(
49+
"String must contain exactly 4 character(s)"
50+
);
51+
}
52+
53+
try {
54+
await z.string().length(4).parseAsync("asdaa");
55+
} catch (err) {
56+
expect((err as z.ZodError).issues[0].message).toEqual(
57+
"String must contain exactly 4 character(s)"
58+
);
59+
}
60+
});
61+
2662
test("string min", async () => {
2763
try {
2864
await z.string().min(4).parseAsync("asd");

0 commit comments

Comments
 (0)