Skip to content

Commit 6ad4086

Browse files
authored
Merge pull request #6 from glideapps/zod-json-schema-type
Use zod's JSON Schema type, and implement better array support
2 parents 4fa902a + f53ce8e commit 6ad4086

File tree

4 files changed

+425
-79
lines changed

4 files changed

+425
-79
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zod-from-json-schema",
3-
"version": "0.4.0",
3+
"version": "0.4.1",
44
"description": "Creates Zod types from JSON Schema at runtime",
55
"main": "dist/index.js",
66
"module": "dist/index.mjs",

src/index.test.ts

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,328 @@ describe("convertJsonSchemaToZod", () => {
691691
];
692692
expect(() => zodSchema.parse(duplicateObjects)).toThrow();
693693
});
694+
695+
describe("Tuple arrays (items as array)", () => {
696+
it("should handle tuple array with different types", () => {
697+
const jsonSchema = {
698+
$schema: "https://json-schema.org/draft/2020-12/schema",
699+
type: "array",
700+
items: [
701+
{ type: "string" },
702+
{ type: "number" },
703+
{ type: "boolean" }
704+
]
705+
};
706+
707+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
708+
709+
// Valid tuple should pass
710+
expect(() => zodSchema.parse(["hello", 42, true])).not.toThrow();
711+
712+
// Wrong types should fail
713+
expect(() => zodSchema.parse([42, "hello", true])).toThrow();
714+
expect(() => zodSchema.parse(["hello", "world", true])).toThrow();
715+
716+
// Wrong length should fail
717+
expect(() => zodSchema.parse(["hello", 42])).toThrow();
718+
expect(() => zodSchema.parse(["hello", 42, true, "extra"])).toThrow();
719+
});
720+
721+
it("should handle tuple array with single item type", () => {
722+
const jsonSchema = {
723+
$schema: "https://json-schema.org/draft/2020-12/schema",
724+
type: "array",
725+
items: [
726+
{ type: "string" }
727+
]
728+
};
729+
730+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
731+
732+
expect(() => zodSchema.parse(["hello"])).not.toThrow();
733+
expect(() => zodSchema.parse([42])).toThrow();
734+
expect(() => zodSchema.parse(["hello", "world"])).toThrow();
735+
expect(() => zodSchema.parse([])).toThrow();
736+
});
737+
738+
it("should handle empty tuple array", () => {
739+
const jsonSchema = {
740+
$schema: "https://json-schema.org/draft/2020-12/schema",
741+
type: "array",
742+
items: []
743+
};
744+
745+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
746+
747+
expect(() => zodSchema.parse([])).not.toThrow();
748+
expect(() => zodSchema.parse(["anything"])).toThrow();
749+
});
750+
751+
it("should handle tuple array with complex item types", () => {
752+
const jsonSchema = {
753+
$schema: "https://json-schema.org/draft/2020-12/schema",
754+
type: "array",
755+
items: [
756+
{
757+
type: "object",
758+
properties: {
759+
name: { type: "string" }
760+
},
761+
required: ["name"]
762+
},
763+
{ type: "number", minimum: 0 },
764+
{
765+
type: "array",
766+
items: { type: "string" }
767+
}
768+
]
769+
};
770+
771+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
772+
773+
// Valid tuple should pass
774+
expect(() => zodSchema.parse([
775+
{ name: "John" },
776+
5,
777+
["a", "b", "c"]
778+
])).not.toThrow();
779+
780+
// Invalid object should fail
781+
expect(() => zodSchema.parse([
782+
{ age: 25 },
783+
5,
784+
["a", "b", "c"]
785+
])).toThrow();
786+
787+
// Invalid number should fail
788+
expect(() => zodSchema.parse([
789+
{ name: "John" },
790+
-5,
791+
["a", "b", "c"]
792+
])).toThrow();
793+
794+
// Invalid nested array should fail
795+
expect(() => zodSchema.parse([
796+
{ name: "John" },
797+
5,
798+
["a", 123, "c"]
799+
])).toThrow();
800+
});
801+
802+
it("should convert tuple to proper JSON schema", () => {
803+
const jsonSchema = {
804+
$schema: "https://json-schema.org/draft/2020-12/schema",
805+
type: "array",
806+
items: [
807+
{ type: "string" },
808+
{ type: "number" }
809+
]
810+
};
811+
812+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
813+
const resultSchema = z.toJSONSchema(zodSchema);
814+
815+
// Zod converts tuples to use prefixItems (which is correct for draft 2020-12)
816+
expect(resultSchema.type).toEqual("array");
817+
expect(resultSchema.prefixItems).toEqual([
818+
{ type: "string" },
819+
{ type: "number" }
820+
]);
821+
});
822+
});
823+
824+
describe("prefixItems (Draft 2020-12 tuples)", () => {
825+
it("should handle prefixItems with different types", () => {
826+
const jsonSchema = {
827+
$schema: "https://json-schema.org/draft/2020-12/schema",
828+
type: "array",
829+
prefixItems: [
830+
{ type: "string" },
831+
{ type: "number" },
832+
{ type: "boolean" }
833+
]
834+
};
835+
836+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
837+
838+
// Valid tuple should pass
839+
expect(() => zodSchema.parse(["hello", 42, true])).not.toThrow();
840+
841+
// Wrong types should fail
842+
expect(() => zodSchema.parse([42, "hello", true])).toThrow();
843+
expect(() => zodSchema.parse(["hello", "world", true])).toThrow();
844+
845+
// Partial tuples should be allowed - prefixItems doesn't require all items
846+
expect(() => zodSchema.parse(["hello"])).not.toThrow();
847+
expect(() => zodSchema.parse(["hello", 42])).not.toThrow();
848+
849+
// Additional items should be allowed by default with prefixItems
850+
expect(() => zodSchema.parse(["hello", 42, true, "extra"])).not.toThrow();
851+
expect(() => zodSchema.parse(["hello", 42, true, 999, { any: "thing" }])).not.toThrow();
852+
});
853+
854+
it("should handle prefixItems with single item type", () => {
855+
const jsonSchema = {
856+
$schema: "https://json-schema.org/draft/2020-12/schema",
857+
type: "array",
858+
prefixItems: [
859+
{ type: "string" }
860+
]
861+
};
862+
863+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
864+
865+
expect(() => zodSchema.parse(["hello"])).not.toThrow();
866+
expect(() => zodSchema.parse([42])).toThrow();
867+
expect(() => zodSchema.parse(["hello", "world"])).not.toThrow(); // extra items allowed
868+
expect(() => zodSchema.parse([])).not.toThrow(); // empty array is valid - no items required
869+
});
870+
871+
it("should handle empty prefixItems array", () => {
872+
const jsonSchema = {
873+
$schema: "https://json-schema.org/draft/2020-12/schema",
874+
type: "array",
875+
prefixItems: []
876+
};
877+
878+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
879+
880+
expect(() => zodSchema.parse([])).not.toThrow();
881+
expect(() => zodSchema.parse(["anything"])).not.toThrow(); // extra items allowed with empty prefixItems
882+
expect(() => zodSchema.parse([1, 2, 3])).not.toThrow();
883+
});
884+
885+
it("should handle prefixItems with complex nested types", () => {
886+
const jsonSchema = {
887+
$schema: "https://json-schema.org/draft/2020-12/schema",
888+
type: "array",
889+
prefixItems: [
890+
{
891+
type: "object",
892+
properties: {
893+
id: { type: "number" },
894+
name: { type: "string" }
895+
},
896+
required: ["id", "name"]
897+
},
898+
{
899+
type: "array",
900+
items: { type: "string" }
901+
},
902+
{ type: "number", minimum: 0, maximum: 100 }
903+
]
904+
};
905+
906+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
907+
908+
// Valid tuple should pass
909+
expect(() => zodSchema.parse([
910+
{ id: 1, name: "Alice" },
911+
["tag1", "tag2"],
912+
50
913+
])).not.toThrow();
914+
915+
// Invalid object should fail
916+
expect(() => zodSchema.parse([
917+
{ name: "Alice" }, // missing id
918+
["tag1", "tag2"],
919+
50
920+
])).toThrow();
921+
922+
// Invalid array should fail
923+
expect(() => zodSchema.parse([
924+
{ id: 1, name: "Alice" },
925+
["tag1", 123], // number in string array
926+
50
927+
])).toThrow();
928+
929+
// Invalid number should fail
930+
expect(() => zodSchema.parse([
931+
{ id: 1, name: "Alice" },
932+
["tag1", "tag2"],
933+
150 // exceeds maximum
934+
])).toThrow();
935+
});
936+
937+
it("should validate prefixItems behavior correctly", () => {
938+
const jsonSchema = {
939+
$schema: "https://json-schema.org/draft/2020-12/schema",
940+
type: "array",
941+
prefixItems: [
942+
{ type: "string" },
943+
{ type: "number" }
944+
]
945+
};
946+
947+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
948+
949+
// Test the behavior instead of schema round-trip since we use custom validation
950+
expect(() => zodSchema.parse(["hello", 42])).not.toThrow();
951+
expect(() => zodSchema.parse(["hello"])).not.toThrow();
952+
expect(() => zodSchema.parse([])).not.toThrow();
953+
expect(() => zodSchema.parse(["hello", 42, "extra"])).not.toThrow();
954+
expect(() => zodSchema.parse([42, "hello"])).toThrow();
955+
});
956+
957+
it("should handle prefixItems with constraints", () => {
958+
const jsonSchema = {
959+
$schema: "https://json-schema.org/draft/2020-12/schema",
960+
type: "array",
961+
prefixItems: [
962+
{ type: "string", minLength: 2 },
963+
{ type: "number", minimum: 0 }
964+
],
965+
minItems: 2,
966+
maxItems: 2
967+
};
968+
969+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
970+
971+
expect(() => zodSchema.parse(["hi", 5])).not.toThrow();
972+
expect(() => zodSchema.parse(["a", 5])).toThrow(); // string too short
973+
expect(() => zodSchema.parse(["hi", -1])).toThrow(); // number too small
974+
});
975+
976+
it("should handle prefixItems with items: false (strict tuple)", () => {
977+
const jsonSchema = {
978+
$schema: "https://json-schema.org/draft/2020-12/schema",
979+
type: "array",
980+
prefixItems: [
981+
{ type: "string" },
982+
{ type: "number" }
983+
],
984+
items: false
985+
};
986+
987+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
988+
989+
expect(() => zodSchema.parse(["hello", 42])).not.toThrow();
990+
expect(() => zodSchema.parse(["hello", 42, "extra"])).toThrow(); // no additional items allowed
991+
expect(() => zodSchema.parse(["hello"])).not.toThrow(); // partial tuple OK
992+
expect(() => zodSchema.parse([])).not.toThrow(); // empty array OK
993+
});
994+
995+
it("should handle prefixItems with items schema (constrained additional items)", () => {
996+
const jsonSchema = {
997+
$schema: "https://json-schema.org/draft/2020-12/schema",
998+
type: "array",
999+
prefixItems: [
1000+
{ type: "string" },
1001+
{ type: "number" }
1002+
],
1003+
items: { type: "boolean" }
1004+
};
1005+
1006+
const zodSchema = convertJsonSchemaToZod(jsonSchema);
1007+
1008+
expect(() => zodSchema.parse(["hello", 42])).not.toThrow();
1009+
expect(() => zodSchema.parse(["hello", 42, true])).not.toThrow();
1010+
expect(() => zodSchema.parse(["hello", 42, true, false])).not.toThrow();
1011+
expect(() => zodSchema.parse(["hello", 42, "string"])).toThrow(); // additional item wrong type
1012+
expect(() => zodSchema.parse(["hello"])).not.toThrow(); // partial tuple OK
1013+
expect(() => zodSchema.parse([])).not.toThrow(); // empty array OK
1014+
});
1015+
});
6941016
});
6951017
});
6961018

0 commit comments

Comments
 (0)