Skip to content

Commit 28c156e

Browse files
fix: apply description and default metadata to enum, const, and not schemas in fromJSONSchema (#5758)
* fix: apply description and default to enum, const, and not schemas in fromJSONSchema Fixes #5732 Early returns for enum, const, and not: {} (never) schemas in convertBaseSchema() bypassed the metadata application block at the end of the function. Extract metadata application into a helper function applyBaseMetadata() and call it from each of the affected early-return paths so that description and default are consistently honored across all schema types. * refactor: consolidate annotation metadata application in convertSchema Move `description` and `default` handling out of `convertBaseSchema` and into a single application site at the end of `convertSchema`, after composition keywords (anyOf/oneOf/allOf) and wrappers (nullable/readOnly) have been applied. This eliminates the original bug class — early returns in `convertBaseSchema` for `enum`, `const`, and `not: {}` silently dropped description/default — without needing per-branch helper calls. As a side effect it also fixes the same latent bug for the `type: [...]` array expansion path. Apply order is `default` → extraMeta → `describe`, so the `.describe()` clone sits at the top of the parent chain. `_zod.parent` inheritance in `$ZodRegistry.get()` then keeps `extraMeta` reachable from the returned reference, while `schema.description` continues to resolve via globalRegistry as before. Adds tests for description/default on `type: [...]` arrays and for the description+default+anyOf composition cases. --------- Co-authored-by: Colin McDonnell <[email protected]>
1 parent e06af5d commit 28c156e

2 files changed

Lines changed: 124 additions & 12 deletions

File tree

packages/zod/src/v4/classic/from-json-schema.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -527,14 +527,6 @@ function convertBaseSchema(schema: JSONSchema.JSONSchema, ctx: ConversionContext
527527
throw new Error(`Unsupported type: ${type}`);
528528
}
529529

530-
// Apply metadata
531-
if (schema.description) {
532-
zodSchema = zodSchema.describe(schema.description);
533-
}
534-
if (schema.default !== undefined) {
535-
zodSchema = zodSchema.default(schema.default);
536-
}
537-
538530
return zodSchema;
539531
}
540532

@@ -586,26 +578,32 @@ function convertSchema(schema: JSONSchema.JSONSchema | boolean, ctx: ConversionC
586578
baseSchema = z.readonly(baseSchema);
587579
}
588580

589-
// Collect metadata: core schema keywords and unrecognized keys
581+
// Apply `default` so it wraps the fully-composed schema. This ensures
582+
// `parse(undefined) -> default` works regardless of which branch of
583+
// `convertBaseSchema` produced the inner schema (enum/const/not/typed/etc.).
584+
if (schema.default !== undefined) {
585+
baseSchema = baseSchema.default(schema.default);
586+
}
587+
588+
// Collect non-description annotation metadata into the user-supplied
589+
// registry. Description is handled separately below via `.describe()` to
590+
// preserve the contract that `schema.description` reads from globalRegistry.
590591
const extraMeta: Record<string, unknown> = {};
591592

592-
// Core schema keywords that should be captured as metadata
593593
const coreMetadataKeys = ["$id", "id", "$comment", "$anchor", "$vocabulary", "$dynamicRef", "$dynamicAnchor"];
594594
for (const key of coreMetadataKeys) {
595595
if (key in schema) {
596596
extraMeta[key] = schema[key];
597597
}
598598
}
599599

600-
// Content keywords - store as metadata
601600
const contentMetadataKeys = ["contentEncoding", "contentMediaType", "contentSchema"];
602601
for (const key of contentMetadataKeys) {
603602
if (key in schema) {
604603
extraMeta[key] = schema[key];
605604
}
606605
}
607606

608-
// Unrecognized keys (custom metadata)
609607
for (const key of Object.keys(schema)) {
610608
if (!RECOGNIZED_KEYS.has(key)) {
611609
extraMeta[key] = schema[key];
@@ -616,6 +614,13 @@ function convertSchema(schema: JSONSchema.JSONSchema | boolean, ctx: ConversionC
616614
ctx.registry.add(baseSchema, extraMeta);
617615
}
618616

617+
// Apply description last. `.describe()` clones the schema and sets
618+
// `_zod.parent` on the clone, so registry lookups on the returned reference
619+
// still resolve `extraMeta` via parent inheritance.
620+
if (schema.description) {
621+
baseSchema = baseSchema.describe(schema.description);
622+
}
623+
619624
return baseSchema;
620625
}
621626

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,3 +732,110 @@ test("contentEncoding and contentMediaType are stored as metadata", () => {
732732
expect(meta?.contentEncoding).toBe("base64");
733733
expect(meta?.contentMediaType).toBe("image/png");
734734
});
735+
736+
test("description on enum schema is applied", () => {
737+
const schema = fromJSONSchema({
738+
enum: ["red", "green", "blue"],
739+
description: "A color value",
740+
});
741+
expect(schema.description).toBe("A color value");
742+
expect(schema.parse("red")).toBe("red");
743+
});
744+
745+
test("description on const schema is applied", () => {
746+
const schema = fromJSONSchema({
747+
const: "hello",
748+
description: "A greeting",
749+
});
750+
expect(schema.description).toBe("A greeting");
751+
expect(schema.parse("hello")).toBe("hello");
752+
});
753+
754+
test("description on not: {} (never) schema is applied", () => {
755+
const schema = fromJSONSchema({
756+
not: {},
757+
description: "A never schema",
758+
});
759+
expect(schema.description).toBe("A never schema");
760+
expect(() => schema.parse("anything")).toThrow();
761+
});
762+
763+
test("default on enum schema is applied", () => {
764+
const schema = fromJSONSchema({
765+
enum: ["red", "green", "blue"],
766+
default: "red",
767+
});
768+
expect(schema.parse(undefined)).toBe("red");
769+
});
770+
771+
test("default on const schema is applied", () => {
772+
const schema = fromJSONSchema({
773+
const: "hello",
774+
default: "hello",
775+
});
776+
expect(schema.parse(undefined)).toBe("hello");
777+
});
778+
779+
test("description and default on enum schema are both applied", () => {
780+
const schema = fromJSONSchema({
781+
enum: ["red", "green", "blue"],
782+
description: "A color value",
783+
default: "red",
784+
});
785+
expect(schema.description).toBe("A color value");
786+
expect(schema.parse(undefined)).toBe("red");
787+
expect(schema.parse("green")).toBe("green");
788+
});
789+
790+
test("description on type-array schema is applied", () => {
791+
const schema = fromJSONSchema({
792+
type: ["string", "number"],
793+
description: "A string or number",
794+
} as any);
795+
expect(schema.description).toBe("A string or number");
796+
expect(schema.parse("hello")).toBe("hello");
797+
expect(schema.parse(42)).toBe(42);
798+
});
799+
800+
test("default on type-array schema is applied", () => {
801+
const schema = fromJSONSchema({
802+
type: ["string", "number"],
803+
default: "fallback",
804+
} as any);
805+
expect(schema.parse(undefined)).toBe("fallback");
806+
expect(schema.parse("hello")).toBe("hello");
807+
expect(schema.parse(42)).toBe(42);
808+
});
809+
810+
test("description on schema with anyOf is applied to the outer schema", () => {
811+
const schema = fromJSONSchema({
812+
description: "Either a string or a number",
813+
anyOf: [{ type: "string" }, { type: "number" }],
814+
});
815+
expect(schema.description).toBe("Either a string or a number");
816+
expect(schema.parse("hello")).toBe("hello");
817+
expect(schema.parse(42)).toBe(42);
818+
});
819+
820+
test("default on schema with anyOf is applied to the outer schema", () => {
821+
const schema = fromJSONSchema({
822+
default: "fallback",
823+
anyOf: [{ type: "string" }, { type: "number" }],
824+
});
825+
expect(schema.parse(undefined)).toBe("fallback");
826+
expect(schema.parse(42)).toBe(42);
827+
});
828+
829+
test("description and unrecognized metadata coexist on the same schema", () => {
830+
const customRegistry = z.registry<{ "x-custom"?: string; description?: string }>();
831+
const schema = fromJSONSchema(
832+
{
833+
type: "string",
834+
description: "A custom string",
835+
"x-custom": "value",
836+
},
837+
{ registry: customRegistry }
838+
);
839+
expect(schema.description).toBe("A custom string");
840+
expect(customRegistry.get(schema)?.["x-custom"]).toBe("value");
841+
});

0 commit comments

Comments
 (0)