From 7f833c9fec133b01d8725e282da9c8b02e488614 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Wed, 12 Aug 2026 13:27:57 -0400 Subject: [PATCH 1/3] fix(schema): keep annotations across encodings, closes #7192 - 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 --- ...fix-schema-annotations-across-encodings.md | 21 +++++ .../src/internal/schema/toRepresentation.ts | 85 +++++++++++++---- .../test/schema/toJsonSchemaDocument.test.ts | 93 +++++++++++++++++-- 3 files changed, 177 insertions(+), 22 deletions(-) create mode 100644 .changeset/fix-schema-annotations-across-encodings.md diff --git a/.changeset/fix-schema-annotations-across-encodings.md b/.changeset/fix-schema-annotations-across-encodings.md new file mode 100644 index 00000000000..f24d903cb40 --- /dev/null +++ b/.changeset/fix-schema-annotations-across-encodings.md @@ -0,0 +1,21 @@ +--- +"effect": patch +--- + +Keep documentation annotations when lowering a schema whose encoded form differs from its type, closes #7192. + +`Schema.toJsonSchemaDocument` builds each node from the last link of the encoding chain, and read annotations only from that link. Any schema that encodes to a different shape therefore lost its `title`, `description`, `examples` and the other JSON Schema annotations — silently, with no error or warning. `Schema.Number` was the most visible case, since it encodes to a union that also accepts `"NaN"`, `"Infinity"` and `"-Infinity"`: + +```ts +import { Schema } from "effect" + +const schema = Schema.Number.annotate({ description: "d" }) + +Schema.toJsonSchemaDocument(schema).schema +// before: { anyOf: [{ type: "number" }, { type: "string", enum: ["Infinity", "-Infinity", "NaN"] }] } +// after: { anyOf: [{ type: "number" }, { type: "string", enum: ["Infinity", "-Infinity", "NaN"] }], description: "d" } +``` + +`Schema.BigInt`, `Schema.Date`, `Schema.Option`, `Schema.ReadonlyMap`, `Schema.Unknown`, `Schema.Void`, `Schema.Undefined`, `Schema.ObjectKeyword` and `bigint` literals were affected the same way, and are fixed too. + +Annotations declared closer to the encoded side still win, so a link that rewrites the shape of the data keeps control of how the result is described. diff --git a/packages/effect/src/internal/schema/toRepresentation.ts b/packages/effect/src/internal/schema/toRepresentation.ts index 0816d04e38c..55ba6e1f299 100644 --- a/packages/effect/src/internal/schema/toRepresentation.ts +++ b/packages/effect/src/internal/schema/toRepresentation.ts @@ -35,6 +35,50 @@ function annotationsField(annotations: A | undefined): { readonly annotations return annotations === undefined ? undefined : { annotations } } +/** + * Collects the documentation annotations that sit on the type side of an + * encoding chain, so that projecting an AST with `getLastEncoding` does not + * silently discard them. + * + * Only the keys in `jsonSchemaAnnotationKeys` travel: they describe the shape + * of the data and stay accurate once the value is encoded. Everything else + * (`representation`, `expected`, `identifier`, the `to*` hooks, ...) is bound to + * the node that declared it and is intentionally left behind. + * + * Annotations closer to the encoded side win: a link that rewrites the shape of + * the data gets to describe the result, and may already have folded the type + * side description into its own. + */ +function carriedAnnotations( + input: SchemaAST.AST, + encoded: SchemaAST.AST +): Schema.Annotations.Annotations | undefined { + let out: Record | undefined + let ast = input + while (ast !== encoded && ast.encoding !== undefined) { + const annotations = ast.annotations + if (annotations !== undefined) { + for (const key of InternalAnnotations.jsonSchemaAnnotationKeys) { + const value = annotations[key] + if (value !== undefined) { + out ??= {} + InternalRecord.assignProperty(out, key, value) + } + } + } + ast = ast.encoding[ast.encoding.length - 1].to + } + return out +} + +function withCarriedAnnotations( + annotations: A | undefined, + carried: Schema.Annotations.Annotations | undefined +): A | undefined { + if (carried === undefined) return annotations + return (annotations === undefined ? carried : { ...carried, ...annotations }) as A +} + function hasShareableStructure( ast: SchemaAST.AST, isAnonymousReferenceAllowed: Options["isAnonymousReferenceAllowed"] @@ -145,10 +189,14 @@ function fromASTs( : SchemaAST.annotate(ast, { identifier: reference }) } - function makeReference(reference: string, ast: SchemaAST.AST): SchemaRepresentation.Reference { + function makeReference( + reference: string, + ast: SchemaAST.AST, + carried?: Schema.Annotations.Annotations | undefined + ): SchemaRepresentation.Reference { if (!Object.hasOwn(references, reference) && !buildingReferences.has(reference)) { buildingReferences.add(reference) - const representation = on(ast) + const representation = on(ast, carried) buildingReferences.delete(reference) InternalRecord.assignProperty(references, reference, representation) } @@ -197,10 +245,11 @@ function fromASTs( function recur(input: SchemaAST.AST): SchemaRepresentation.Representation { const ast = SchemaAST.getLastEncoding(input) const owner = SchemaAST.getContextOwner(ast) + const carried = carriedAnnotations(input, ast) const referenceIdentifier = resolveReferenceIdentifier(input, ast) if (referenceIdentifier !== undefined) { const reference = getReference(referenceIdentifier.identifier, owner) - return makeReference(reference, annotateReference(ast, referenceIdentifier, reference)) + return makeReference(reference, annotateReference(ast, referenceIdentifier, reference), carried) } const found = anonymousReferences.get(owner) @@ -213,12 +262,12 @@ function fromASTs( const reference = getReference(`${ast._tag}_`, owner, "") anonymousReferences.set(owner, reference) return isShared - ? makeReference(reference, ast) + ? makeReference(reference, ast, carried) : { _tag: "Reference", $ref: reference } } visiting.add(owner) - const representation = on(ast) + const representation = on(ast, carried) visiting.delete(owner) const reference = anonymousReferences.get(owner) @@ -230,15 +279,19 @@ function fromASTs( return representation } - function on(ast: SchemaAST.AST): SchemaRepresentation.Representation { + function on( + ast: SchemaAST.AST, + carried?: Schema.Annotations.Annotations | undefined + ): SchemaRepresentation.Representation { const checks = fromChecks(ast.checks) + const annotations = withCarriedAnnotations(ast.annotations, carried) switch (ast._tag) { case "Declaration": return { _tag: "Declaration", typeParameters: ast.typeParameters.map((ast) => recur(ast)), checks, - ...fromDeclarationAnnotations(ast.annotations) + ...fromDeclarationAnnotations(annotations) } case "Null": case "Undefined": @@ -255,35 +308,35 @@ function fromASTs( return { _tag: ast._tag, checks, - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } case "Literal": return { _tag: "Literal", literal: ast.literal, checks, - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } case "UniqueSymbol": return { _tag: "UniqueSymbol", symbol: ast.symbol, checks, - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } case "Enum": return { _tag: "Enum", enums: ast.enums, checks, - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } case "TemplateLiteral": return { _tag: "TemplateLiteral", parts: ast.parts.map((ast) => recur(ast)), checks, - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } case "Arrays": return { @@ -299,7 +352,7 @@ function fromASTs( }), rest: ast.rest.map((ast) => recur(ast)), checks, - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } case "Objects": return { @@ -320,7 +373,7 @@ function fromASTs( type: recur(index.type) })), checks, - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } case "Union": return { @@ -328,14 +381,14 @@ function fromASTs( types: ast.types.map((ast) => recur(ast)), mode: ast.mode, checks, - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } case "Suspend": return { _tag: "Suspend", checks: [], thunk: recur(ast.thunk()), - ...annotationsField(ast.annotations) + ...annotationsField(annotations) } } } diff --git a/packages/effect/test/schema/toJsonSchemaDocument.test.ts b/packages/effect/test/schema/toJsonSchemaDocument.test.ts index 333e73b057d..bbc2ddda4b6 100644 --- a/packages/effect/test/schema/toJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/toJsonSchemaDocument.test.ts @@ -584,6 +584,12 @@ describe("toJsonSchemaDocument", () => { "type": "string" } }) + assertJsonSchemaDocument(schema.annotate({ description: "a" }), { + schema: { + "type": "string", + "description": "a" + } + }) }) it("URL", () => { @@ -786,7 +792,9 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument( schema.annotate({ description: "a" }), { - schema: {} + schema: { + "description": "a" + } } ) }) @@ -805,7 +813,8 @@ describe("toJsonSchemaDocument", () => { schema.annotate({ description: "a" }), { schema: { - "type": "null" + "type": "null", + "description": "a" } } ) @@ -825,7 +834,8 @@ describe("toJsonSchemaDocument", () => { schema.annotate({ description: "a" }), { schema: { - "type": "null" + "type": "null", + "description": "a" } } ) @@ -844,6 +854,18 @@ describe("toJsonSchemaDocument", () => { } } ) + assertJsonSchemaDocument( + schema.annotate({ description: "a" }), + { + schema: { + "type": "string", + "description": "a", + "allOf": [ + { "pattern": "^-?\\d+$" } + ] + } + } + ) }) it("Symbol", () => { @@ -1480,7 +1502,64 @@ describe("toJsonSchemaDocument", () => { "anyOf": [ { "type": "number" }, { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] } - ] + ], + "description": "a" + } + } + ) + }) + + it("Number & annotate keeps every documentation annotation", () => { + assertJsonSchemaDocument( + Schema.Number.annotate({ title: "t", description: "a", examples: [1, 2] }), + { + schema: { + "anyOf": [ + { "type": "number" }, + { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] } + ], + "title": "t", + "description": "a", + "examples": [1, 2] + } + } + ) + }) + + it("Number & annotate inside a Struct", () => { + assertJsonSchemaDocument( + Schema.Struct({ + value: Schema.Number.annotate({ description: "a" }) + }), + { + schema: { + type: "object", + properties: { + value: { + anyOf: [ + { type: "number" }, + { type: "string", enum: ["Infinity", "-Infinity", "NaN"] } + ], + description: "a" + } + }, + required: ["value"], + additionalProperties: false + } + } + ) + }) + + it("Number & annotate & annotate", () => { + assertJsonSchemaDocument( + Schema.Number.annotate({ description: "a" }).annotate({ description: "b" }), + { + schema: { + "anyOf": [ + { "type": "number" }, + { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] } + ], + "description": "b" } } ) @@ -1763,7 +1842,8 @@ describe("toJsonSchemaDocument", () => { "anyOf": [ { "type": "array" }, { "type": "object" } - ] + ], + "description": "a" } } ) @@ -1855,7 +1935,8 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "enum": ["1"] + "enum": ["1"], + "description": "a" } } ) From 736aca4f7c462da4797759706e8ebbd3b1c176e7 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Wed, 12 Aug 2026 13:39:26 -0400 Subject: [PATCH 2/3] test(schema): cover the annotation carry paths across encodings - 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 --- .../representation/toRepresentation.test.ts | 94 ++++++++++++++++++- .../test/schema/toJsonSchemaDocument.test.ts | 72 ++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/packages/effect/test/schema/representation/toRepresentation.test.ts b/packages/effect/test/schema/representation/toRepresentation.test.ts index 4c8ae33a136..259c47f304d 100644 --- a/packages/effect/test/schema/representation/toRepresentation.test.ts +++ b/packages/effect/test/schema/representation/toRepresentation.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Schema, SchemaAST, SchemaRepresentation } from "effect" +import { Schema, SchemaAST, SchemaRepresentation, SchemaTransformation } from "effect" describe("SchemaRepresentation.toRepresentation", () => { describe("node conversion", () => { @@ -165,6 +165,98 @@ describe("SchemaRepresentation.toRepresentation", () => { }) }) + it("carries type-side documentation annotations onto the encoded representation", () => { + const schema = Schema.NumberFromString.annotate({ title: "t", description: "d", examples: ["1"] }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { + _tag: "String", + annotations: { + title: "t", + description: "d", + examples: ["1"], + expected: "a string that will be decoded as a number" + }, + checks: [] + }, + references: {} + }) + }) + + it("leaves non documentation annotations on the type side", () => { + const schema = Schema.NumberFromString.annotate({ expected: "custom expected" }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { + _tag: "String", + annotations: { expected: "a string that will be decoded as a number" }, + checks: [] + }, + references: {} + }) + }) + + it("collects documentation annotations from every link of an encoding chain", () => { + // Unknown -> Declaration(Date) -> String, so the annotations sit two links apart + const schema = Schema.Date.annotate({ title: "inner", description: "inner" }).pipe( + Schema.decodeTo(Schema.Unknown, SchemaTransformation.passthrough()) + ).annotate({ title: "outer", examples: ["1970-01-01T00:00:00.000Z"] }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.toCodecJson(schema).ast), { + representation: { + _tag: "String", + annotations: { + // the innermost link wins the keys it declares, the outermost still contributes the rest + title: "inner", + description: "inner", + examples: ["1970-01-01T00:00:00.000Z"], + expected: "a string that will be decoded as a Date" + }, + checks: [] + }, + references: {} + }) + }) + + it("prefers an encoded-side documentation annotation over a type-side one", () => { + const schema = Schema.NumberFromString.pipe( + Schema.annotateEncoded({ description: "encoded" }), + Schema.annotate({ description: "type", title: "t" }) + ) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { + _tag: "String", + annotations: { + title: "t", + description: "encoded", + expected: "a string that will be decoded as a number" + }, + checks: [] + }, + references: {} + }) + }) + + it("carries documentation annotations onto a referenced encoded representation", () => { + const schema = Schema.NumberFromString.annotate({ identifier: "Finite", description: "d" }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { _tag: "Reference", $ref: "FiniteEncoded" }, + references: { + FiniteEncoded: { + _tag: "String", + annotations: { + description: "d", + expected: "a string that will be decoded as a number", + "~identifier": "Finite" + }, + checks: [] + } + } + }) + }) + it("uses a type-side identifier as a fallback for the encoded representation", () => { const schema = Schema.NumberFromString.annotate({ identifier: "Finite" }) diff --git a/packages/effect/test/schema/toJsonSchemaDocument.test.ts b/packages/effect/test/schema/toJsonSchemaDocument.test.ts index bbc2ddda4b6..9655cfd5a97 100644 --- a/packages/effect/test/schema/toJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/toJsonSchemaDocument.test.ts @@ -592,6 +592,60 @@ describe("toJsonSchemaDocument", () => { }) }) + it("Date & annotate drops values that are not valid JSON", () => { + assertJsonSchemaDocument( + Schema.Date.annotate({ default: new Date(0), examples: [new Date(0)], description: "a" }), + { + schema: { + "type": "string", + "description": "a" + } + } + ) + }) + + it("does not carry the type-side expected annotation to the encoded side", () => { + // `expected` describes the decoded value, so the encoded String keeps its own + // ("a string that will be decoded as a Date"), not the Declaration's ("a valid Date"). + assertJsonSchemaDocument( + Schema.Date.annotate({ title: "T" }), + { + schema: { + "type": "string", + "title": "T", + "description": "a string that will be decoded as a Date" + } + }, + { generateDescriptions: true } + ) + // Option's Declaration is `expected: "Option"`, which must not describe the encoded union + assertJsonSchemaDocument( + Schema.Option(Schema.String), + { + schema: { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { "type": "string", "enum": ["Some"] }, + "value": { "type": "string" } + }, + "required": ["_tag", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { "_tag": { "type": "string", "enum": ["None"] } }, + "required": ["_tag"], + "additionalProperties": false + } + ] + } + }, + { generateDescriptions: true } + ) + }) + it("URL", () => { const schema = Schema.URL assertJsonSchemaDocument(schema, { @@ -1565,6 +1619,24 @@ describe("toJsonSchemaDocument", () => { ) }) + it("Number & annotate & identifier", () => { + assertJsonSchemaDocument( + Schema.Number.annotate({ identifier: "N", description: "a" }), + { + schema: { "$ref": "#/$defs/NEncoded" }, + definitions: { + NEncoded: { + "anyOf": [ + { "type": "number" }, + { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] } + ], + "description": "a" + } + } + } + ) + }) + it("Number & annotateKey", () => { assertJsonSchemaDocument( Schema.Struct({ From e5f7d222a1a24f4801c0e8825b05b888833bf550 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Wed, 12 Aug 2026 13:53:56 -0400 Subject: [PATCH 3/3] fix(schema): correct new test types and refresh the OpenAPI fixture - 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 --- .../test/schema/representation/toRepresentation.test.ts | 6 +++--- .../platform/node/test/__snapshots__/HttpApi.test.ts.snap | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/effect/test/schema/representation/toRepresentation.test.ts b/packages/effect/test/schema/representation/toRepresentation.test.ts index 259c47f304d..a9b8296d7c1 100644 --- a/packages/effect/test/schema/representation/toRepresentation.test.ts +++ b/packages/effect/test/schema/representation/toRepresentation.test.ts @@ -166,7 +166,7 @@ describe("SchemaRepresentation.toRepresentation", () => { }) it("carries type-side documentation annotations onto the encoded representation", () => { - const schema = Schema.NumberFromString.annotate({ title: "t", description: "d", examples: ["1"] }) + const schema = Schema.NumberFromString.annotate({ title: "t", description: "d", examples: [1] }) assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { representation: { @@ -174,7 +174,7 @@ describe("SchemaRepresentation.toRepresentation", () => { annotations: { title: "t", description: "d", - examples: ["1"], + examples: [1], expected: "a string that will be decoded as a number" }, checks: [] @@ -199,7 +199,7 @@ describe("SchemaRepresentation.toRepresentation", () => { it("collects documentation annotations from every link of an encoding chain", () => { // Unknown -> Declaration(Date) -> String, so the annotations sit two links apart const schema = Schema.Date.annotate({ title: "inner", description: "inner" }).pipe( - Schema.decodeTo(Schema.Unknown, SchemaTransformation.passthrough()) + Schema.decodeTo(Schema.Unknown, SchemaTransformation.passthroughSubtype()) ).annotate({ title: "outer", examples: ["1970-01-01T00:00:00.000Z"] }) assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.toCodecJson(schema).ast), { diff --git a/packages/platform/node/test/__snapshots__/HttpApi.test.ts.snap b/packages/platform/node/test/__snapshots__/HttpApi.test.ts.snap index 4afc5ec7d3b..493a7ba3751 100644 --- a/packages/platform/node/test/__snapshots__/HttpApi.test.ts.snap +++ b/packages/platform/node/test/__snapshots__/HttpApi.test.ts.snap @@ -63,6 +63,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` }, "UserEncoded": { "additionalProperties": false, + "description": "Some description for User", "properties": { "createdAt": { "type": "string",