Skip to content

fix: strip redundant id from $defs entries in toJSONSchema#5759

Merged
colinhacks merged 2 commits into
colinhacks:mainfrom
mibragimov:fix/to-json-schema-meta-id
Apr 28, 2026
Merged

fix: strip redundant id from $defs entries in toJSONSchema#5759
colinhacks merged 2 commits into
colinhacks:mainfrom
mibragimov:fix/to-json-schema-meta-id

Conversation

@mibragimov

Copy link
Copy Markdown
Contributor

Problem

When a schema is annotated with .meta({ id: "Name" }), the id is used as the key in $defs so that other schemas can reference it via $ref. However, the id was also being included verbatim in the definition body, producing redundant output:

{
  "$defs": {
    "Inner": {
      "id": "Inner",
      "type": "string"
    }
  }
}

The presence of id inside $defs entries is misleading — it looks like a JSON Schema $id, but it's just Zod's internal id tag. The id is already encoded as the $defs key itself, so including it in the value is redundant.

Fixes #5731

Solution

In extractToDef(), after copying the schema into seen.def, delete the id property from the definition body when it matches defId — the id is already encoded as the $defs key name.

Before:

{ "$defs": { "Inner": { "id": "Inner", "type": "string" } } }

After:

{ "$defs": { "Inner": { "type": "string" } } }

Tests

Updated 6 existing snapshot tests that documented the old (buggy) behavior to reflect the correct output.

Fixes colinhacks#5731

When a schema is annotated with .meta({ id: "Name" }), the id is used
as the key in $defs so that other schemas can reference it. However,
the id was also included verbatim in the definition body, producing
redundant (and invalid) output like:

  "$defs": {
    "Inner": {
      "id": "Inner",   ← redundant
      "type": "string"
    }
  }

In extractToDef(), after copying the schema into seen.def, delete id
from the definition body when it matches the defId — the id is already
encoded as the $defs key name.
@colinhacks

Copy link
Copy Markdown
Owner

@pullfrog review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed — no issues found.

Task list (5/5 completed)
  • Checkout PR and read the diff
  • Read changed files and trace logic
  • Check linked issue #5731 for context
  • Verify snapshot updates match the code change
  • Submit review

Pullfrog  | View workflow run | Using Claude Opus𝕏

The `id` in `.meta()` is a Zod-specific registration tag that controls
$defs extraction — it is not user-facing JSON Schema metadata. Strip it
from the output body in two places:

1. From each $defs entry where it equals the defId (already covered).
2. From the root schema where it equals the user-set metadata id.

Move the strip to finalize() so that the override callback still observes
`jsonSchema.id` on extracted definitions — preserves backward-compat for
userland override handlers that read it.

Adds explicit tests for:
- $defs strip (draft-2020-12)
- definitions strip (draft-04, where `id` is a reserved keyword)
- root strip
- override-callback observability
@colinhacks

Copy link
Copy Markdown
Owner

Hey @mibragimov — thanks for this. I pushed a follow-up commit that extends the fix slightly. Reasoning is that we treat meta({ id }) as a registration directive for $defs extraction, so leaking it into the output body is a leaky abstraction across all targets (and is actively wrong on draft-04, where id is a reserved keyword that sets a base URI). The expanded change:

  1. Strip from $defs entries (your original fix, kept).
  2. Strip from the root schema when meta({ id }) is set on the root — same reasoning, the registration tag shouldn't surface there either.
  3. Move the strip into finalize() so that user-authored override callbacks still observe jsonSchema.id on extracted definitions before it gets cleaned up. Avoids a subtle behavior change to the override API.
  4. Tests for: $defs strip (draft-2020-12), definitions strip (draft-04), root strip, and override-callback observability.

Snapshot diff for top-level readonly was updated accordingly.

Happy to discuss any of this if you'd rather take a different approach.

@pullfrog

pullfrog Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

TL;DR — Strips the redundant id property from $defs entries and root schemas produced by toJSONSchema. The Zod-internal id tag (set via .meta({ id })) was leaking into the JSON Schema output body where it could be confused with JSON Schema's $id; it is now removed since the key in $defs already encodes the same information.

Key changes

  • Strip id from $defs entries and root schema in finalize() — after the override callback runs, id is deleted from each $defs definition (when it matches the defId key) and from the root schema (when it matches the schema's meta id), preventing the Zod registration tag from appearing in the output.
  • Add 4 targeted tests for id stripping — covers draft-2020-12, draft-04, root schema, and the guarantee that the id is still observable inside override callbacks before stripping occurs.
  • Update 6 existing snapshots — removes the now-stripped "id" entries from expected output across multiple test cases.

Summary | 2 files | 2 commits | base: mainfix/to-json-schema-meta-id


Stripping the Zod id tag from JSON Schema output

Before: toJSONSchema included "id": "Name" inside each $defs entry and on the root schema when .meta({ id: "Name" }) was set.
After: The id property is deleted from the serialized output; the name is preserved only as the $defs key and in $ref paths.

The fix lives in finalize() — the single exit point where the JSON Schema tree is assembled. Two one-liner deletes handle the two locations where id can appear:

  1. Root schema — if the top-level schema has a meta id that matches result.id, the property is deleted.
  2. $defs entries — while iterating ctx.seen, if seen.def.id matches the defId key, the property is deleted before inserting into the defs map.
Why strip after overrides instead of earlier?

Override callbacks receive the full jsonSchema object including id, so stripping earlier would break userland code that reads jsonSchema.id to customize output. The new test "id is observable in override callback" explicitly verifies this ordering.

to-json-schema.ts · to-json-schema.test.ts

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues. Reviewed the following changes:

  • Moved id stripping from extractDefs into finalize, keeping extraction logic focused on refs/defs and output cleanup in the finalize pass
  • Added root schema id stripping — the previous version only stripped from $defs entries, but the registration tag also leaked onto root schemas with .meta({ id })
  • Added four targeted tests: $defs stripping (draft-2020-12), definitions stripping (draft-04), root schema stripping, and a test confirming id remains visible to override callbacks before the strip runs
  • Updated existing inline snapshots to reflect the removal of leaked id fields

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks colinhacks merged commit b6a3b33 into colinhacks:main Apr 28, 2026
7 checks passed
@colinhacks

Copy link
Copy Markdown
Owner

Merged 👍 The follow-up moves the id strip into finalize() inside toJSONSchema so it also clears the root schema, not just $defs entries — meta({ id }) on the root was leaking the same way. Closes #5731 — thanks for the fix!

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.toJSONSchema: .meta({ id }) leaks id into $defs entries — should use $id or omit it

2 participants