Skip to content

fix: apply description and default metadata to enum, const, and not schemas in fromJSONSchema#5758

Merged
colinhacks merged 3 commits into
colinhacks:mainfrom
mibragimov:fix/from-json-schema-metadata
Apr 28, 2026
Merged

fix: apply description and default metadata to enum, const, and not schemas in fromJSONSchema#5758
colinhacks merged 3 commits into
colinhacks:mainfrom
mibragimov:fix/from-json-schema-metadata

Conversation

@mibragimov
Copy link
Copy Markdown
Contributor

Problem

z.fromJSONSchema() ignores description and default fields when the JSON Schema uses enum, const, or not: {} (never). This happens because these paths use early returns in convertBaseSchema() before reaching the metadata application block at the bottom of the function.

Minimal reproduction:

const schema = z.fromJSONSchema({
  enum: ["red", "green", "blue"],
  description: "A color value",
  default: "red",
});

schema.description; // undefined ❌ (expected "A color value")
schema.parse(undefined); // throws ❌ (expected "red")

Fixes #5732

Solution

Extract the metadata application (description + default) into a helper function applyBaseMetadata() and call it from each of the affected early-return paths:

  • not: {}z.never()
  • enumz.enum() / z.literal() / z.union()
  • constz.literal()

Tests

Added five new tests covering:

  • description on enum schema
  • description on const schema
  • description on not: {} (never) schema
  • default on enum schema
  • default on const schema

… fromJSONSchema

Fixes colinhacks#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.
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.
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 28, 2026

TL;DRfromJSONSchema silently dropped description and default metadata for enum, const, and not: {} schemas because convertBaseSchema returned early before reaching the metadata block. This PR moves metadata application into convertSchema so it runs unconditionally after schema construction.

Key changes

  • Move description and default application from convertBaseSchema to convertSchema — ensures metadata is applied to every schema variant (enum, const, not, typed, anyOf, etc.) rather than only typed schemas that reach the end of convertBaseSchema
  • Add 12 tests for metadata on non-typed schemas — covers description and default on enum, const, not/never, type-array, anyOf, and combined scenarios including registry coexistence

Summary | 2 files | 3 commits | base: mainfix/from-json-schema-metadata


Metadata application lifted to convertSchema

Before: description and default were applied at the bottom of convertBaseSchema, which enum, const, and not: {} branches never reached due to early returns.
After: Both keywords are applied in convertSchema after convertBaseSchema completes, guaranteeing they wrap the fully-composed schema regardless of which branch produced it.

The default call is placed first so .default() wraps the inner schema before any registry metadata is attached. The description call is placed last because .describe() clones the schema — placing it after ctx.registry.add() ensures registry lookups still resolve via _zod.parent inheritance.

Why is ordering important here?

.describe() creates a clone with a _zod.parent pointer back to the original. If extraMeta is registered on the original, the clone inherits it through parent traversal. Calling .describe() before registry.add() would register metadata on a reference that is immediately discarded by the clone.

from-json-schema.ts · from-json-schema.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

# Conflicts:
#	packages/zod/src/v4/classic/from-json-schema.ts
@colinhacks colinhacks merged commit 28c156e into colinhacks:main Apr 28, 2026
4 of 6 checks passed
}

// Collect metadata: core schema keywords and unrecognized keys
// Apply `default` so it wraps the fully-composed schema. This ensures
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import os
from datetime import date
from enum import Enum

from pydantic import BaseModel, Field

from xai_sdk import Client
from xai_sdk.chat import system, user

Pydantic Schemas

class Currency(str, Enum):
USD = "USD"
EUR = "EUR"
GBP = "GBP"

class LineItem(BaseModel):
description: str = Field(description="Description of the item or service")
quantity: int = Field(description="Number of units", ge=1)
unit_price: float = Field(description="Price per unit", ge=0)

class Address(BaseModel):
street: str = Field(description="Street address")
city: str = Field(description="City")
postal_code: str = Field(description="Postal/ZIP code")
country: str = Field(description="Country")

class Invoice(BaseModel):
vendor_name: str = Field(description="Name of the vendor")
vendor_address: Address = Field(description="Vendor's address")
invoice_number: str = Field(description="Unique invoice identifier")
invoice_date: date = Field(description="Date the invoice was issued")
line_items: list[LineItem] = Field(description="List of purchased items/services")
total_amount: float = Field(description="Total amount due", ge=0)
currency: Currency = Field(description="Currency of the invoice")

client = Client(api_key=os.getenv("XAI_API_KEY"))
chat = client.chat.create(model="grok-4.20-reasoning")

chat.append(system("Given a raw invoice, carefully analyze the text and extract the invoice data into JSON format."))
chat.append(
user("""
Vendor: Acme Corp, 123 Main St, Springfield, IL 62704
Invoice Number: INV-2025-001
Date: 2025-02-10
Items: - Widget A, 5 units, $10.00 each - Widget B, 2 units, $15.00 each
Total: $80.00 USD
""")
)

The parse method returns a tuple of the full response object as well as the parsed pydantic object.

response, invoice = chat.parse(Invoice)
assert isinstance(invoice, Invoice)

Can access fields of the parsed invoice object directly

print(invoice.vendor_name)
print(invoice.invoice_number)
print(invoice.invoice_date)
print(invoice.line_items)
print(invoice.total_amount)
print(invoice.currency)

Can also access fields from the raw response object such as the content.

In this case, the content is the JSON schema representation of the parsed invoice object

print(response.content)

@colinhacks
Copy link
Copy Markdown
Owner

Merged. Pushed a small follow-up on top: rather than calling applyBaseMetadata from each early-return branch in fromJSONSchema, I consolidated description and default application into a single site at the end of convertSchema, after composition keywords and wrappers run. Same fix for enum, const, and not, and it picks up the latent bug on the type: [...] array path for free. Thanks for tracking this one down 👍

Note: this comment was produced by an AI coding assistant.

@colinhacks
Copy link
Copy Markdown
Owner

Landed in Zod 4.4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

z.fromJSONSchema() does not support metadata for enums, literals and not/never

3 participants