Skip to content

fix(schema): keep annotations across encodings - #7203

Open
spencerbeggs wants to merge 5 commits into
Effect-TS:mainfrom
spencerbeggs:feat/schema-numbers-annotations
Open

fix(schema): keep annotations across encodings#7203
spencerbeggs wants to merge 5 commits into
Effect-TS:mainfrom
spencerbeggs:feat/schema-numbers-annotations

Conversation

@spencerbeggs

@spencerbeggs spencerbeggs commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #7192.

What changed

Schema.toJsonSchemaDocument now keeps title, description, examples and the other JSON Schema annotations on schemas whose encoded form differs from their type. Schema.Number was the reported case, and it now behaves like Schema.String and Schema.NullOr do:

Schema.toJsonSchemaDocument(Schema.Number.annotate({ description: "d" })).schema
// before: { anyOf: [{ type: "number" }, { type: "string", enum: ["Infinity", "-Infinity", "NaN"] }] }
// after:  { anyOf: [...], description: "d" }

Root cause

The annotation was not lost in the JSON Schema compiler — it never reached it.

internal/schema/toRepresentation.ts builds every representation from SchemaAST.getLastEncoding(input), which walks to the final link of the encoding chain, and then reads annotations off that node alone. Schema.Number.toCodecJson() calls replaceEncoding(this, [numberToJson(this.checks)]), so the annotated Number node is the type side and the encoded side is a fresh Union([finite, nonFiniteLiterals]) carrying no annotations. Everything on the type side was dropped before the JSON Schema pass ran.

That also explains the observations in the issue. String and NullOr have no encoding, so input === encoded and nothing is lost. Finite and Int short-circuit toCodecJson() back to this, so they keep the annotation — the allOf nesting there is the separate, pre-existing behavior of .annotate() targeting the last check, which this PR does not change. And the 4-branch anyOf in toJsonSchemaDocument.ts is unrelated: the 2-branch shape the caller sees comes from the encoded Union, not from that code path.

Scope

Schema.Number was not the only casualty — the defect applies to every schema with an encoding link. Schema.BigInt, Schema.Date, Schema.URL, Schema.Option, Schema.ReadonlyMap, Schema.Unknown, Schema.Void, Schema.Undefined, Schema.ObjectKeyword and bigint literals all dropped their annotations the same way, and six existing tests asserted that behavior. They are updated here, and the fix covers all of them rather than special-casing Number.

The fix

toRepresentation now carries the type-side annotations forward across the encoding chain. Two deliberate constraints:

  • Only jsonSchemaAnnotationKeys travel. representation, expected, identifier, the to* hooks and anything else stay bound to the node that declared them. Carrying expected in particular would be wrong — it describes the decoded value, so Schema.Option(...) would start emitting description: "Option" for its encoded union under generateDescriptions: true.
  • Annotations closer to the encoded side win. This one is not arbitrary: unstable/ai/internal/structured-output.ts composes the type-side description into its own ("Tuple encoded as an object with numeric string keys …; description"). My first attempt let the type side win and broke four AnthropicStructuredOutput/OpenAiStructuredOutput tests, which is the evidence that a link rewriting the shape of the data gets to describe the result. This also matches the existing precedent in resolveReferenceIdentifier, which prefers the encoded identifier and falls back to the input's.

Tests

The fix is covered at both layers, and every assertion was checked by mutation rather than by inspection. Reverting the fix fails 17 tests; each of the following single-line mutations was also confirmed to fail at least one test:

Mutation Caught by
Drop the carry entirely 17 tests, across both files
Flip the precedence to type-side-wins prefers an encoded-side documentation annotation over a type-side one, plus 4 structured-output tests
Carry every annotation key instead of jsonSchemaAnnotationKeys 8 tests, mostly identifier/reference ones
Replace the chain walk with a single step collects documentation annotations from every link of an encoding chain
Reverse the precedence between chain links same
Skip the carry on the reference path carries documentation annotations onto a referenced encoded representation

test/schema/representation/toRepresentation.test.ts gets the unit-level assertions (carry, key filtering, precedence, the reference path, a two-link Unknown > Declaration > String chain). test/schema/toJsonSchemaDocument.test.ts gets the user-visible ones, including that Schema.Date.annotate({ default: new Date(0) }) still drops the non-JSON value rather than emitting it.

Known remaining gap

.annotate() lands on the last check when a schema has one, and checks do not travel to the encoded side of a transformation. So this case is still annotation-free and is not fixed here:

Schema.toJsonSchemaDocument(Schema.FiniteFromString.annotate({ description: "d" })).schema
// { type: "string" }  <- description still dropped

I left it out deliberately. Reading through InternalAnnotations.resolve instead of ast.annotations would pick it up, but it would also double-emit for Schema.Number.check(...).annotate(...), where the checks do travel via numberToJson(this.checks) and the annotation already surfaces inside the finite branch's allOf. Untangling that is the separate question of what a check annotation means on an encoded shape, which the existing allOf nesting on Schema.Finite.annotate(...) already raises. Happy to take it on in this PR or a follow-up, whichever you prefer.

Verification

  • pnpm test run (full workspace) — 9463 passed. The one remaining failure, Prompt.date > renders two-digit years, teen ordinals, and noon meridiem correctly, is locale-dependent, fails identically on a clean main, and passes in CI.
  • pnpm check — clean. deno check . — clean. pnpm test-types --target '>=5.9' — 2072 tests, 5102 assertions, clean.
  • pnpm lint and dprint check report nothing on the changed files.

One snapshot moved, and it is worth a look: HttpApi's OpenAPI fixture now emits "description": "Some description for User" on the UserEncoded component schema. User is a Schema.Class declaring that description, and the encoded component was dropping it — while the OpenAPI response objects already carried it through a different path. The two agree now.

Note for reviewers

The precedence rule is the one judgement call in this PR. It is now pinned directly by prefers an encoded-side documentation annotation over a type-side one as well as by the structured-output tests. If you would rather the type side win, that is a one-line flip in withCarriedAnnotations — but it would need the structured-output description composition reworked to match.

- Carry documentation annotations from the type side of an encoding chain onto the representation, so a schema that encodes to a different shape keeps its title, description and examples
- Let annotations closer to the encoded side win, since a link that rewrites the data may already have folded the type side description into its own

Signed-off-by: C. Spencer Beggs <spencer@beggs.codes>
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a464e39

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node Patch
@effect/platform-node-shared Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/vitest Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@spencerbeggs spencerbeggs changed the title fix(schema): keep annotations across encodings, closes #7192 fix(schema): keep annotations across encodings Aug 12, 2026
- Assert the carry at the representation layer: documentation keys travel, expected and the other behavioral keys stay behind, and the encoded side wins a collision
- Cover the paths the JSON Schema tests missed: a referenced encoded definition, a two-link encoding chain, and default and examples values that are not valid JSON

Signed-off-by: C. Spencer Beggs <spencer@beggs.codes>
- Type the encoding-chain test against the real signatures: numeric examples for NumberFromString, and passthroughSubtype for the Date to unknown link
- Update the HttpApi OpenAPI snapshot, where the UserEncoded component schema now carries the description its Schema.Class declares

Signed-off-by: C. Spencer Beggs <spencer@beggs.codes>
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 6.96 KB 6.96 KB 0.00 KB (0.00%)
batching.ts 9.76 KB 9.76 KB 0.00 KB (0.00%)
brand.ts 6.55 KB 6.55 KB 0.00 KB (0.00%)
cache.ts 10.67 KB 10.67 KB 0.00 KB (0.00%)
config.ts 21.10 KB 21.10 KB 0.00 KB (0.00%)
differ.ts 20.04 KB 20.04 KB 0.00 KB (0.00%)
http-client.ts 21.64 KB 21.64 KB 0.00 KB (0.00%)
logger.ts 10.91 KB 10.91 KB 0.00 KB (0.00%)
metric.ts 8.89 KB 8.89 KB 0.00 KB (0.00%)
optic.ts 6.71 KB 6.71 KB 0.00 KB (0.00%)
pubsub.ts 14.94 KB 14.94 KB 0.00 KB (0.00%)
queue.ts 11.61 KB 11.61 KB 0.00 KB (0.00%)
schedule.ts 10.77 KB 10.77 KB 0.00 KB (0.00%)
schema-class.ts 19.66 KB 19.66 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.61 KB 29.61 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.00 KB 25.85 KB +0.15 KB (+0.56%)
schema-string-transformation.ts 13.53 KB 13.53 KB 0.00 KB (0.00%)
schema-string.ts 11.03 KB 11.03 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.33 KB 15.33 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 21.78 KB 21.78 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.37 KB 24.21 KB +0.15 KB (+0.64%)
schema-toCodecJson.ts 19.00 KB 19.00 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.82 KB 18.82 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.69 KB 18.69 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.99 KB 22.85 KB +0.14 KB (+0.62%)
schema-toRepresentation.ts 19.48 KB 19.33 KB +0.15 KB (+0.78%)
schema.ts 18.91 KB 18.91 KB 0.00 KB (0.00%)
stm.ts 12.69 KB 12.69 KB 0.00 KB (0.00%)
stream.ts 9.71 KB 9.71 KB 0.00 KB (0.00%)

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

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema.toJsonSchemaDocument drops annotations on Schema.Number

1 participant