Skip to content
This repository was archived by the owner on Feb 24, 2026. It is now read-only.
1 change: 1 addition & 0 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@zodios/core": "^10.3.1",
"axios": "^1.6.0",
"cac": "^6.7.14",
"fast-deep-equal": "^3.1.3",
"handlebars": "^4.7.7",
"openapi-types": "^12.0.2",
"openapi3-ts": "3.1.0",
Expand Down
24 changes: 14 additions & 10 deletions lib/src/openApiToZod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,16 +212,17 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option

if (schemaType === "array") {
if (schema.items) {
const arrayValidations = getZodChainableArrayValidations(schema);
const arrayChain = arrayValidations ? `.${arrayValidations}` : '';

return code.assign(
`z.array(${
getZodSchema({ schema: schema.items, ctx, meta, options }).toString()
}${
getZodChain({
schema: schema.items as SchemaObject,
meta: { ...meta, isRequired: true },
options,
})
})${readonly}`
`z.array(${getZodSchema({ schema: schema.items, ctx, meta, options }).toString()
}${getZodChain({
schema: schema.items as SchemaObject,
meta: { ...meta, isRequired: true },
options,
})
})${arrayChain}${readonly}`
);
}

Expand Down Expand Up @@ -308,7 +309,6 @@ export const getZodChain = ({ schema, meta, options }: ZodChainArgs) => {
match(schema.type)
.with("string", () => chains.push(getZodChainableStringValidations(schema)))
.with("number", "integer", () => chains.push(getZodChainableNumberValidations(schema)))
.with("array", () => chains.push(getZodChainableArrayValidations(schema)))
.otherwise(() => void 0);

if (typeof schema.description === "string" && schema.description !== "" && options?.withDescription) {
Expand Down Expand Up @@ -459,5 +459,9 @@ const getZodChainableArrayValidations = (schema: SchemaObject) => {
validations.push(`max(${schema.maxItems})`);
}


if (schema.uniqueItems === true) {
validations.push(`refine((arr) => { const unique: any[] = []; for (const item of arr) { if (unique.some(u => isEqual(u, item))) { return false; } unique.push(item); } return true; }, { message: "Items must be unique" })`);
}
return validations.join(".");
};
7 changes: 5 additions & 2 deletions lib/tests/array-body-with-chains-tag-group-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ test("array-body-with-chains-tag-group-strategy", async () => {
"Test": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core";
import { z } from "zod";

const putTest_Body = z.array(z.object({ testItem: z.string() }).partial());
const putTest_Body = z
.array(z.object({ testItem: z.string() }).partial())
.min(1)
.max(10);

export const schemas = {
putTest_Body,
Expand All @@ -72,7 +75,7 @@ test("array-body-with-chains-tag-group-strategy", async () => {
{
name: "body",
type: "Body",
schema: putTest_Body.min(1).max(10),
schema: putTest_Body,
},
],
response: z.void(),
Expand Down
8 changes: 4 additions & 4 deletions lib/tests/export-all-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ describe("export-all-types", () => {

expect(data).toEqual({
schemas: {
Settings: "z.object({ theme_color: z.string(), features: Features.min(1) }).partial().passthrough()",
Settings: "z.object({ theme_color: z.string(), features: Features }).partial().passthrough()",
Author: "z.object({ name: z.union([z.string(), z.number()]).nullable(), title: Title.min(1).max(30), id: Id, mail: z.string(), settings: Settings }).partial().passthrough()",
Features: "z.array(z.string())",
Features: "z.array(z.string()).min(1)",
Song: "z.object({ name: z.string(), duration: z.number() }).partial().passthrough()",
Playlist:
"z.object({ name: z.string(), author: Author, songs: z.array(Song) }).partial().passthrough().and(Settings)",
Expand Down Expand Up @@ -200,9 +200,9 @@ describe("export-all-types", () => {

const Title = z.string();
const Id = z.number();
const Features = z.array(z.string());
const Features = z.array(z.string()).min(1);
const Settings: z.ZodType<Settings> = z
.object({ theme_color: z.string(), features: Features.min(1) })
.object({ theme_color: z.string(), features: Features })
.partial()
.passthrough();
const Author: z.ZodType<Author> = z
Expand Down
57 changes: 57 additions & 0 deletions lib/tests/unique-items.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { getZodSchema } from "../src/openApiToZod";
import { test, expect } from "vitest";

test("uniqueItems validation", () => {
// Test basic uniqueItems on string array
expect(
getZodSchema({
schema: {
type: "array",
items: { type: "string" },
uniqueItems: true
}
}).toString()
).toMatchInlineSnapshot(
'"z.array(z.string()).refine((arr) => { const unique: any[] = []; for (const item of arr) { if (unique.some(u => isEqual(u, item))) { return false; } unique.push(item); } return true; }, { message: "Items must be unique" })"'
);

// Test array without uniqueItems (should not have refine)
expect(
getZodSchema({
schema: {
type: "array",
items: { type: "string" }
}
}).toString()
).toMatchInlineSnapshot(
'"z.array(z.string())"'
);

// Test uniqueItems: false (should not have refine)
expect(
getZodSchema({
schema: {
type: "array",
items: { type: "string" },
uniqueItems: false
}
}).toString()
).toMatchInlineSnapshot(
'"z.array(z.string())"'
);

// Test uniqueItems with minItems and maxItems (proper order)
expect(
getZodSchema({
schema: {
type: "array",
items: { type: "string" },
minItems: 2,
maxItems: 5,
uniqueItems: true
}
}).toString()
).toMatchInlineSnapshot(
'"z.array(z.string()).min(2).max(5).refine((arr) => { const unique: any[] = []; for (const item of arr) { if (unique.some(u => isEqual(u, item))) { return false; } unique.push(item); } return true; }, { message: "Items must be unique" })"'
);
});
5 changes: 2 additions & 3 deletions lib/tests/validations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ test("validations", () => {
arrayWithMin: { type: "array", items: { type: "string" }, minItems: 3 },
arrayWithMax: { type: "array", items: { type: "string" }, maxItems: 3 },
arrayWithFormat: { type: "array", items: { type: "string", format: "uuid" } },
// TODO ?
// arrayWithUnique: { type: "array", items: { type: "string" }, uniqueItems: true },
arrayWithUnique: { type: "array", items: { type: "string" }, uniqueItems: true },
//
object: { type: "object", properties: { str: { type: "string" } } },
objectWithRequired: { type: "object", properties: { str: { type: "string" } }, required: ["str"] },
Expand All @@ -62,6 +61,6 @@ test("validations", () => {
},
})
).toMatchInlineSnapshot(
'"z.object({ str: z.string(), strWithLength: z.string().min(3).max(3), strWithMin: z.string().min(3), strWithMax: z.string().max(3), strWithPattern: z.string().regex(/^[a-z]+$/), strWithPatternWithSlash: z.string().regex(/abc\\/def\\/ghi/), email: z.string().email(), hostname: z.string().url(), url: z.string().url(), uuid: z.string().uuid(), number: z.number(), int: z.number().int(), intWithMin: z.number().int().gte(3), intWithMax: z.number().int().lte(3), intWithMinAndMax: z.number().int().gte(3).lte(3), intWithExclusiveMinTrue: z.number().int().gt(3), intWithExclusiveMinFalse: z.number().int().gte(3), intWithExclusiveMin: z.number().int().gt(3), intWithExclusiveMaxTrue: z.number().int().lt(3), intWithExclusiveMaxFalse: z.number().int().lte(3), intWithExclusiveMax: z.number().int().lt(3), intWithMultipleOf: z.number().int().multipleOf(3), bool: z.boolean(), array: z.array(z.string()), arrayWithMin: z.array(z.string()).min(3), arrayWithMax: z.array(z.string()).max(3), arrayWithFormat: z.array(z.string().uuid()), object: z.object({ str: z.string() }).passthrough(), objectWithRequired: z.object({ str: z.string() }).passthrough(), oneOf: z.union([z.string(), z.number()]), anyOf: z.union([z.string(), z.number()]), allOf: z.string().and(z.number()), nested: z.record(z.number()), nestedNullable: z.record(z.number().nullable()) }).passthrough()"'
'"z.object({ str: z.string(), strWithLength: z.string().min(3).max(3), strWithMin: z.string().min(3), strWithMax: z.string().max(3), strWithPattern: z.string().regex(/^[a-z]+$/), strWithPatternWithSlash: z.string().regex(/abc\\/def\\/ghi/), email: z.string().email(), hostname: z.string().url(), url: z.string().url(), uuid: z.string().uuid(), number: z.number(), int: z.number().int(), intWithMin: z.number().int().gte(3), intWithMax: z.number().int().lte(3), intWithMinAndMax: z.number().int().gte(3).lte(3), intWithExclusiveMinTrue: z.number().int().gt(3), intWithExclusiveMinFalse: z.number().int().gte(3), intWithExclusiveMin: z.number().int().gt(3), intWithExclusiveMaxTrue: z.number().int().lt(3), intWithExclusiveMaxFalse: z.number().int().lte(3), intWithExclusiveMax: z.number().int().lt(3), intWithMultipleOf: z.number().int().multipleOf(3), bool: z.boolean(), array: z.array(z.string()), arrayWithMin: z.array(z.string()).min(3), arrayWithMax: z.array(z.string()).max(3), arrayWithFormat: z.array(z.string().uuid()), arrayWithUnique: z.array(z.string()).refine((arr) => { const unique: any[] = []; for (const item of arr) { if (unique.some(u => isEqual(u, item))) { return false; } unique.push(item); } return true; }, { message: "Items must be unique" }), object: z.object({ str: z.string() }).passthrough(), objectWithRequired: z.object({ str: z.string() }).passthrough(), oneOf: z.union([z.string(), z.number()]), anyOf: z.union([z.string(), z.number()]), allOf: z.string().and(z.number()), nested: z.record(z.number()), nestedNullable: z.record(z.number().nullable()) }).passthrough()"'
);
});