Skip to content

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

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

fix(schema): keep annotations across encodings#7203
spencerbeggs wants to merge 11 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.

This is a fix I put together for a problem I ran into; the analysis below is my reading of the code, not something a maintainer has signed off on. I have tried to make the premises, the judgement calls and the gaps easy to check separately, so any one of them can be rejected without discarding the rest. Happy to take this in a different direction.

What I am proposing

Schema.toJsonSchemaDocument 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 ends up behaving like Schema.String and Schema.NullOr already 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, as I read it

The annotation is not lost in the JSON Schema compiler — it never reaches 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 is dropped before the JSON Schema pass runs.

That also accounts for the other 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 are in the same position and keep the annotation. 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 is not the only schema affected — as far as I can tell this 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. Those are updated here, and the fix covers all of them rather than special-casing Number.

If you would rather scope this to Number only, say so — that is a smaller change and I am not attached to the broad version.

The fix, and the two judgement calls in it

toRepresentation carries the type-side annotations forward across the encoding chain. Two decisions in there are mine, and either can be reversed cheaply:

1. Only jsonSchemaAnnotationKeys travel. representation, expected, identifier, the to* hooks and everything else stay bound to the node that declared them. Carrying expected in particular looked wrong to me: it describes the decoded value, so Schema.Option(...) would start emitting description: "Option" for its encoded union under generateDescriptions: true.

2. Annotations closer to the encoded side win. 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 what convinced me that a link rewriting the shape of the data should get to describe the result. It also lines up with resolveReferenceIdentifier, which prefers the encoded identifier and falls back to the input's. If you read the precedence the other way, it is a one-line flip in withCarriedAnnotations, but the structured-output description composition would need reworking to match.

Tests

Assertions were checked by mutation rather than by inspection — each row below is a single-line change to the fix, re-run against the full workspace suite on this branch:

Mutation Tests that fail Where
Drop the carry entirely 22 toJsonSchemaDocument (14), toRepresentation (4), OpenApiGenerator (3), HttpApi (1)
Flip the precedence to type-side-wins 5 toRepresentation, AnthropicStructuredOutput, OpenAiStructuredOutput
Carry every annotation key instead of jsonSchemaAnnotationKeys 9 toRepresentation, toRepresentations, toJsonSchemaMultiDocument, toJsonSchemaDocument
Walk one step instead of the whole chain 1 toRepresentation
Reverse the precedence between chain links 1 toRepresentation
Skip the carry on the reference path 3 toRepresentation, toJsonSchemaDocument, HttpApi

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

Known gap: annotations that land on a check

.annotate() targets 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 on purpose. The obvious fix is to read through InternalAnnotations.resolve instead of ast.annotations in carriedAnnotations. I tried that and measured it, and it trades one class of dropped annotation for another, because resolve returns only the last check's annotations and does not merge them with the node's:

Input This PR Via resolve
FiniteFromString.annotate({description}) {type:"string"} {type:"string", description:"d"}
Number.annotate({description}).check(isGreaterThan(0)) {anyOf:[…], description:"d"} {anyOf:[…]}
BigInt.annotate({description}).check(…) {type:"string", description:"d", pattern:…} {type:"string", pattern:…}
Number.check(isGreaterThan(0)).annotate({description}) {anyOf:[{…, description:"d"}, …]} {anyOf:[{…, description:"d"}, …], description:"d"} (duplicated)

Worth flagging clearly: the full suite passes either way. No test currently defends either the regression in rows 2–3 or the duplication in row 4, so green CI does not settle this one — it is a behavior question, not a test question.

A merge of the two ({...ast.annotations, ...lastCheckAnnotations}) would close the gap without the regression, and I am glad to add it. What stops me from doing it unilaterally is row 4: it leaves a description both inside the finite branch and at the top level, and deciding whether that is redundant-but-fine or actually wrong is really the question of what a check annotation means on an encoded shape. That reads to me like your call rather than mine, and it is adjacent to #7336. Happy to fold it into this PR, split it into a follow-up, or drop it entirely.

Interaction with #7336

Rebased on top of the check compaction. One expectation in toJsonSchemaDocument.test.ts needed updating: the annotated Schema.BigInt assertion is added by this branch, so it was not covered by #7336's sweep and still expected the pre-compaction shape.

-{ type: "string", description: "a", allOf: [{ pattern: "^-?\\d+$" }] }
+{ type: "string", description: "a", pattern: "^-?\\d+$" }

The compaction also supersedes something I claimed in an earlier version of this description: Schema.Finite.annotate({description}) no longer nests the annotation under allOf, it now reads { type: "number", description: "d" }. That paragraph is gone.

Verification

On the current head of this branch:

  • Full workspace suite — 387 files, 9954 passed, 1 expected fail, 55 skipped.
  • pnpm check — clean.
  • pnpm lint (oxlint + dprint check) — clean.
  • pnpm test-types --target '>=5.9' — 182 files, 2144 tests, 5270 assertions.
  • CI green on all 13 jobs, Node, Deno and Bun.

One snapshot moved and I think it is worth a look rather than a rubber stamp: 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, but if the previous asymmetry was deliberate I have missed the reason.

Questions for reviewers

  1. Is the broad scope right, or would you prefer this narrowed to Schema.Number?
  2. Is "encoded side wins" the precedence you want? The structured-output composition depends on it, but that is evidence, not a mandate.
  3. The check-annotation gap above — fold in, follow-up, or leave alone?

- 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: 15020bc

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.86 KB 29.86 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.00 KB 25.85 KB +0.15 KB (+0.57%)
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.39 KB 24.23 KB +0.16 KB (+0.65%)
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 23.32 KB 23.18 KB +0.14 KB (+0.60%)
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%)

Effect-TS#7336 compacts a lone inlineable check into its parent instead of
wrapping it in `allOf`. That PR updated every expectation present on
main, but the annotated `Schema.BigInt` assertion is added by this
branch, so the merge left it in the pre-compaction shape and it was the
only failing test on Node and Deno.

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

gcanti commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

#7349

@gcanti gcanti closed this Aug 19, 2026
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 ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema.toJsonSchemaDocument drops annotations on Schema.Number

2 participants