Skip to content

Commit

Permalink
Validate array uniqueness (colinhacks#2672)
Browse files Browse the repository at this point in the history
* feat: implement primitive array uniqueness

* chore: update yarn.lock

* feat: implement array of complex objects uniqueness

* chore: rollback yarn.lock

* refactor: minor update _arrayUnique

* chore: minor change yarn.lock

* feat: implement custom message support

* feat: add showDuplicates param

* chore: TS types update

* chore: TS types II

* refactor: ArrayMessageFunction

* refactor: simplify array uniqueness implementation

* docs: add documentation for array uniqueness

* Regen lock

* Fix readme

---------

Co-authored-by: Frederic Woelffel <[email protected]>
Co-authored-by: Colin McDonnell <[email protected]>
  • Loading branch information
3 people committed May 3, 2024
1 parent fe2bdc5 commit 14e6b5d
Show file tree
Hide file tree
Showing 11 changed files with 1,079 additions and 146 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
- [`.element`](#element)
- [`.nonempty`](#nonempty)
- [`.min/.max/.length`](#minmaxlength)
- [`.unique`](#unique)
- [Tuples](#tuples)
- [Unions](#unions)
- [Discriminated unions](#discriminated-unions)
Expand Down Expand Up @@ -1404,6 +1405,18 @@ z.string().array().length(5); // must contain 5 items exactly

Unlike `.nonempty()` these methods do not change the inferred type.

### `.unique`

```ts
// All elements must be unique
z.object({ id: z.string() }).array().unique();

// All elements must be unique based on the id property
z.object({ id: z.string(), name: z.string() })
.array()
.unique({ identifier: (elt) => elt.id });
```

## Tuples

Unlike arrays, tuples have a fixed number of elements and each element can have a different type.
Expand Down
13 changes: 13 additions & 0 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
- [`.element`](#element)
- [`.nonempty`](#nonempty)
- [`.min/.max/.length`](#minmaxlength)
- [`.unique`](#unique)
- [Tuples](#tuples)
- [Unions](#unions)
- [Discriminated unions](#discriminated-unions)
Expand Down Expand Up @@ -1404,6 +1405,18 @@ z.string().array().length(5); // must contain 5 items exactly

Unlike `.nonempty()` these methods do not change the inferred type.

### `.unique`

```ts
// All elements must be unique
z.object({ id: z.string() }).array().unique();

// All elements must be unique based on the id property
z.object({ id: z.string(), name: z.string() })
.array()
.unique({ identifier: (elt) => elt.id });
```

## Tuples

Unlike arrays, tuples have a fixed number of elements and each element can have a different type.
Expand Down
7 changes: 7 additions & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const ZodIssueCode = util.arrayToEnum([
"invalid_intersection_types",
"not_multiple_of",
"not_finite",
"uniqueness",
]);

export type ZodIssueCode = keyof typeof ZodIssueCode;
Expand Down Expand Up @@ -142,6 +143,11 @@ export interface ZodNotFiniteIssue extends ZodIssueBase {
code: typeof ZodIssueCode.not_finite;
}

export interface ZodUniquenessIssue<T = unknown> extends ZodIssueBase {
code: typeof ZodIssueCode.uniqueness;
duplicateElements?: Array<T>;
}

export interface ZodCustomIssue extends ZodIssueBase {
code: typeof ZodIssueCode.custom;
params?: { [k: string]: any };
Expand All @@ -165,6 +171,7 @@ export type ZodIssueOptionalMessage =
| ZodInvalidIntersectionTypesIssue
| ZodNotMultipleOfIssue
| ZodNotFiniteIssue
| ZodUniquenessIssue
| ZodCustomIssue;

export type ZodIssue = ZodIssueOptionalMessage & {
Expand Down
85 changes: 85 additions & 0 deletions deno/lib/__tests__/array.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const maxTwo = z.string().array().max(2);
const justTwo = z.string().array().length(2);
const intNum = z.string().array().nonempty();
const nonEmptyMax = z.string().array().nonempty().max(2);
const unique = z.string().array().unique();
const uniqueArrayOfObjects = z
.array(z.object({ name: z.string() }))
.unique({ identifier: (item) => item.name });

type t1 = z.infer<typeof nonEmptyMax>;
util.assertEqual<[string, ...string[]], t1>(true);
Expand All @@ -35,6 +39,14 @@ test("failing validations", () => {
expect(() => intNum.parse([])).toThrow();
expect(() => nonEmptyMax.parse([])).toThrow();
expect(() => nonEmptyMax.parse(["a", "a", "a"])).toThrow();
expect(() => unique.parse(["a", "b", "a"])).toThrow();
expect(() =>
uniqueArrayOfObjects.parse([
{ name: "Leo" },
{ name: "Joe" },
{ name: "Leo" },
])
).toThrow();
});

test("parse empty array in nonempty", () => {
Expand Down Expand Up @@ -70,3 +82,76 @@ test("parse should fail given sparse array", () => {

expect(() => schema.parse(new Array(3))).toThrow();
});

test("continue parsing despite array of primitives uniqueness error", () => {
const schema = z.number().array().unique();

const result = schema.safeParse([1, 1, 2, 2, 3]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Values must be unique");
}
});

test("continue parsing despite array of objects uniqueness error", () => {
const schema = z.array(z.object({ name: z.string() })).unique({
identifier: (item) => item.name,
showDuplicates: true,
});

const result = schema.safeParse([
{ name: "Leo" },
{ name: "Joe" },
{ name: "Leo" },
]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Element(s): 'Leo' not unique");
}
});

test("returns custom error message without duplicate elements", () => {
const schema = z.number().array().unique({ message: "Custom message" });

const result = schema.safeParse([1, 1, 2, 2, 3]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Custom message");
}
});

test("returns error message with duplicate elements", () => {
const schema = z.number().array().unique({ showDuplicates: true });

const result = schema.safeParse([1, 1, 2, 2, 3]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Element(s): '1,2' not unique");
}
});

test("returns custom error message with duplicate elements", () => {
const schema = z
.number()
.array()
.unique({
message: (item) => `Custom message: '${item}' are not unique`,
showDuplicates: true,
});

const result = schema.safeParse([1, 1, 2, 2, 3]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Custom message: '1,2' are not unique");
}
});
5 changes: 5 additions & 0 deletions deno/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ const errorMap: ZodErrorMap = (issue, _ctx) => {
case ZodIssueCode.not_finite:
message = "Number must be finite";
break;
case ZodIssueCode.uniqueness:
message = issue.duplicateElements?.length
? `Element(s): '${issue.duplicateElements}' not unique`
: "Values must be unique";
break;
default:
message = _ctx.defaultError;
util.assertNever(issue);
Expand Down
38 changes: 38 additions & 0 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2151,6 +2151,13 @@ export interface ZodArrayDef<T extends ZodTypeAny = ZodTypeAny>
exactLength: { value: number; message?: string } | null;
minLength: { value: number; message?: string } | null;
maxLength: { value: number; message?: string } | null;
uniqueness: {
identifier?: <U extends T["_output"]>(item: U) => unknown;
message?:
| string
| (<U extends T["_output"]>(duplicateItems: U[]) => string);
showDuplicates?: boolean;
} | null;
}

export type ArrayCardinality = "many" | "atleastone";
Expand Down Expand Up @@ -2230,6 +2237,24 @@ export class ZodArray<
}
}

if (def.uniqueness !== null) {
const { identifier, message, showDuplicates } = def.uniqueness;
const duplicates = (
identifier
? (ctx.data as this["_output"][]).map(identifier)
: (ctx.data as this["_output"][])
).filter((item, idx, arr) => arr.indexOf(item) !== idx);
if (duplicates.length) {
addIssueToContext(ctx, {
code: ZodIssueCode.uniqueness,
duplicateElements: showDuplicates ? duplicates : undefined,
message:
typeof message === "function" ? message(duplicates) : message,
});
status.dirty();
}
}

if (ctx.common.async) {
return Promise.all(
([...ctx.data] as any[]).map((item, i) => {
Expand Down Expand Up @@ -2280,6 +2305,18 @@ export class ZodArray<
return this.min(1, message) as any;
}

unique(params: ZodArrayDef<T>["uniqueness"] = {}): this {
const message =
typeof params?.message === "function"
? params.message
: errorUtil.toString(params?.message);

return new ZodArray({
...this._def,
uniqueness: { ...params, message },
}) as any;
}

static create = <T extends ZodTypeAny>(
schema: T,
params?: RawCreateParams
Expand All @@ -2289,6 +2326,7 @@ export class ZodArray<
minLength: null,
maxLength: null,
exactLength: null,
uniqueness: null,
typeName: ZodFirstPartyTypeKind.ZodArray,
...processCreateParams(params),
});
Expand Down
7 changes: 7 additions & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const ZodIssueCode = util.arrayToEnum([
"invalid_intersection_types",
"not_multiple_of",
"not_finite",
"uniqueness",
]);

export type ZodIssueCode = keyof typeof ZodIssueCode;
Expand Down Expand Up @@ -142,6 +143,11 @@ export interface ZodNotFiniteIssue extends ZodIssueBase {
code: typeof ZodIssueCode.not_finite;
}

export interface ZodUniquenessIssue<T = unknown> extends ZodIssueBase {
code: typeof ZodIssueCode.uniqueness;
duplicateElements?: Array<T>;
}

export interface ZodCustomIssue extends ZodIssueBase {
code: typeof ZodIssueCode.custom;
params?: { [k: string]: any };
Expand All @@ -165,6 +171,7 @@ export type ZodIssueOptionalMessage =
| ZodInvalidIntersectionTypesIssue
| ZodNotMultipleOfIssue
| ZodNotFiniteIssue
| ZodUniquenessIssue
| ZodCustomIssue;

export type ZodIssue = ZodIssueOptionalMessage & {
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/array.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const maxTwo = z.string().array().max(2);
const justTwo = z.string().array().length(2);
const intNum = z.string().array().nonempty();
const nonEmptyMax = z.string().array().nonempty().max(2);
const unique = z.string().array().unique();
const uniqueArrayOfObjects = z
.array(z.object({ name: z.string() }))
.unique({ identifier: (item) => item.name });

type t1 = z.infer<typeof nonEmptyMax>;
util.assertEqual<[string, ...string[]], t1>(true);
Expand All @@ -34,6 +38,14 @@ test("failing validations", () => {
expect(() => intNum.parse([])).toThrow();
expect(() => nonEmptyMax.parse([])).toThrow();
expect(() => nonEmptyMax.parse(["a", "a", "a"])).toThrow();
expect(() => unique.parse(["a", "b", "a"])).toThrow();
expect(() =>
uniqueArrayOfObjects.parse([
{ name: "Leo" },
{ name: "Joe" },
{ name: "Leo" },
])
).toThrow();
});

test("parse empty array in nonempty", () => {
Expand Down Expand Up @@ -69,3 +81,76 @@ test("parse should fail given sparse array", () => {

expect(() => schema.parse(new Array(3))).toThrow();
});

test("continue parsing despite array of primitives uniqueness error", () => {
const schema = z.number().array().unique();

const result = schema.safeParse([1, 1, 2, 2, 3]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Values must be unique");
}
});

test("continue parsing despite array of objects uniqueness error", () => {
const schema = z.array(z.object({ name: z.string() })).unique({
identifier: (item) => item.name,
showDuplicates: true,
});

const result = schema.safeParse([
{ name: "Leo" },
{ name: "Joe" },
{ name: "Leo" },
]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Element(s): 'Leo' not unique");
}
});

test("returns custom error message without duplicate elements", () => {
const schema = z.number().array().unique({ message: "Custom message" });

const result = schema.safeParse([1, 1, 2, 2, 3]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Custom message");
}
});

test("returns error message with duplicate elements", () => {
const schema = z.number().array().unique({ showDuplicates: true });

const result = schema.safeParse([1, 1, 2, 2, 3]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Element(s): '1,2' not unique");
}
});

test("returns custom error message with duplicate elements", () => {
const schema = z
.number()
.array()
.unique({
message: (item) => `Custom message: '${item}' are not unique`,
showDuplicates: true,
});

const result = schema.safeParse([1, 1, 2, 2, 3]);

expect(result.success).toEqual(false);
if (!result.success) {
const issue = result.error.issues.find(({ code }) => code === "uniqueness");
expect(issue?.message).toEqual("Custom message: '1,2' are not unique");
}
});
Loading

0 comments on commit 14e6b5d

Please sign in to comment.