Skip to content

Commit 3a42469

Browse files
authored
Remove defaults from JSON Schema for transforming schemas (#4557)
* Add comments * Remove defaults from JSON Schema for transforming schemas
1 parent 33495d5 commit 3a42469

File tree

3 files changed

+154
-8
lines changed

3 files changed

+154
-8
lines changed

packages/zod/src/v4/classic/tests/to-json-schema.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1777,7 +1777,6 @@ test("defaults/prefaults", () => {
17771777
expect(z.toJSONSchema(c, { io: "input" })).toMatchInlineSnapshot(`
17781778
{
17791779
"$schema": "https://json-schema.org/draft/2020-12/schema",
1780-
"default": 1234,
17811780
"type": "string",
17821781
}
17831782
`);
@@ -1954,3 +1953,31 @@ test("use output type for preprocess", () => {
19541953
}
19551954
`);
19561955
});
1956+
1957+
// test("isTransforming", () => {
1958+
// const tx = z.core.isTransforming;
1959+
// expect(tx(z.string())).toEqual(false);
1960+
// expect(tx(z.string().transform((val) => val))).toEqual(true);
1961+
// expect(tx(z.string().pipe(z.string()))).toEqual(false);
1962+
// expect(
1963+
// tx(
1964+
// z
1965+
// .string()
1966+
// .transform((val) => val)
1967+
// .pipe(z.string())
1968+
// )
1969+
// ).toEqual(true);
1970+
1971+
// const a = z.transform((val) => val);
1972+
// expect(tx(z.transform((val) => val))).toEqual(true);
1973+
// expect(tx(a.optional())).toEqual(true);
1974+
1975+
// const b = z.string().optional();
1976+
// expect(tx(b)).toEqual(false);
1977+
1978+
// const c = z.string().prefault("hello");
1979+
// expect(tx(c)).toEqual(false);
1980+
1981+
// const d = z.string().default("hello");
1982+
// expect(tx(d)).toEqual(false);
1983+
// });

packages/zod/src/v4/core/to-json-schema.ts

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -542,12 +542,14 @@ export class JSONSchemaGenerator {
542542
// metadata
543543
const meta = this.metadataRegistry.get(schema);
544544
if (meta) Object.assign(result.schema, meta);
545-
if (this.io === "input" && def.type === "pipe") {
545+
546+
if (this.io === "input" && isTransforming(schema)) {
546547
// examples/defaults only apply to output type of pipe
547548
delete result.schema.examples;
548549
delete result.schema.default;
549-
if (result.schema._prefault) result.schema.default = result.schema._prefault;
550550
}
551+
552+
// set prefault as default
551553
if (this.io === "input" && result.schema._prefault) result.schema.default ??= result.schema._prefault;
552554
delete result.schema._prefault;
553555

@@ -604,6 +606,8 @@ export class JSONSchemaGenerator {
604606
return { defId, ref: defUriPrefix + defId };
605607
};
606608

609+
// stored cached version in `def` property
610+
// remove all properties, set $ref
607611
const extractToDef = (entry: [schemas.$ZodType<unknown, unknown>, Seen]): void => {
608612
if (entry[1].schema.$ref) {
609613
return;
@@ -680,27 +684,31 @@ export class JSONSchemaGenerator {
680684
const seen = this.seen.get(zodSchema)!;
681685
const schema = seen.def ?? seen.schema;
682686

683-
const _schema = { ...schema };
687+
const _cached = { ...schema };
688+
689+
// already seen
684690
if (seen.ref === null) {
685691
return;
686692
}
687693

694+
// flatten ref if defined
688695
const ref = seen.ref;
689-
seen.ref = null;
696+
seen.ref = null; // prevent recursion
690697
if (ref) {
691698
flattenRef(ref, params);
692699

700+
// merge referenced schema into current
693701
const refSchema = this.seen.get(ref)!.schema;
694-
695702
if (refSchema.$ref && params.target === "draft-7") {
696703
schema.allOf = schema.allOf ?? [];
697704
schema.allOf.push(refSchema);
698705
} else {
699706
Object.assign(schema, refSchema);
700-
Object.assign(schema, _schema); // this is to prevent overwriting any fields in the original schema
707+
Object.assign(schema, _cached); // prevent overwriting any fields in the original schema
701708
}
702709
}
703710

711+
// execute overrides
704712
if (!seen.isParent)
705713
this.override({
706714
zodSchema: zodSchema as schemas.$ZodTypes,
@@ -723,6 +731,7 @@ export class JSONSchemaGenerator {
723731

724732
Object.assign(result, root.def);
725733

734+
// build defs object
726735
const defs: JSONSchema.BaseSchema["$defs"] = params.external?.defs ?? {};
727736
for (const entry of this.seen.entries()) {
728737
const seen = entry[1];
@@ -802,3 +811,107 @@ export function toJSONSchema(
802811

803812
return gen.emit(input, _params);
804813
}
814+
815+
function isTransforming(
816+
_schema: schemas.$ZodType,
817+
_ctx?: {
818+
seen: Set<schemas.$ZodType>;
819+
}
820+
): boolean {
821+
const ctx = _ctx ?? { seen: new Set() };
822+
823+
if (ctx.seen.has(_schema)) return false;
824+
ctx.seen.add(_schema);
825+
826+
const schema = _schema as schemas.$ZodTypes;
827+
const def = schema._zod.def;
828+
switch (def.type) {
829+
case "string":
830+
case "number":
831+
case "bigint":
832+
case "boolean":
833+
case "date":
834+
case "symbol":
835+
case "undefined":
836+
case "null":
837+
case "any":
838+
case "unknown":
839+
case "never":
840+
case "void":
841+
case "literal":
842+
case "enum":
843+
case "nan":
844+
case "file":
845+
case "template_literal":
846+
return false;
847+
case "array": {
848+
return isTransforming(def.element, ctx);
849+
}
850+
case "object": {
851+
for (const key in def.shape) {
852+
if (isTransforming(def.shape[key], ctx)) return true;
853+
}
854+
return false;
855+
}
856+
case "union": {
857+
for (const option of def.options) {
858+
if (isTransforming(option, ctx)) return true;
859+
}
860+
return false;
861+
}
862+
case "intersection": {
863+
return isTransforming(def.left, ctx) || isTransforming(def.right, ctx);
864+
}
865+
case "tuple": {
866+
for (const item of def.items) {
867+
if (isTransforming(item, ctx)) return true;
868+
}
869+
if (def.rest && isTransforming(def.rest, ctx)) return true;
870+
return false;
871+
}
872+
case "record": {
873+
return isTransforming(def.keyType, ctx) || isTransforming(def.valueType, ctx);
874+
}
875+
case "map": {
876+
return isTransforming(def.keyType, ctx) || isTransforming(def.valueType, ctx);
877+
}
878+
case "set": {
879+
return isTransforming(def.valueType, ctx);
880+
}
881+
882+
// inner types
883+
case "promise":
884+
case "optional":
885+
case "nonoptional":
886+
case "nullable":
887+
case "readonly":
888+
return isTransforming(def.innerType, ctx);
889+
case "lazy":
890+
return isTransforming(def.getter(), ctx);
891+
case "default": {
892+
return isTransforming(def.innerType, ctx);
893+
}
894+
case "prefault": {
895+
return isTransforming(def.innerType, ctx);
896+
}
897+
case "custom": {
898+
return false;
899+
}
900+
case "transform": {
901+
return true;
902+
}
903+
case "pipe": {
904+
return isTransforming(def.in, ctx) || isTransforming(def.out, ctx);
905+
}
906+
case "success": {
907+
return false;
908+
}
909+
case "catch": {
910+
return false;
911+
}
912+
913+
default:
914+
def satisfies never;
915+
}
916+
throw new Error(`Unknown schema type: ${(def as any).type}`);
917+
}

play.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1-
import { z } from "zod/v3";
1+
import { z } from "zod/v4";
22

33
z;
4+
const a = z
5+
.string()
6+
.transform((val) => val.length)
7+
.default(5);
8+
9+
console.dir(z.toJSONSchema(a, { io: "input" }), { depth: null });

0 commit comments

Comments
 (0)