Skip to content
21 changes: 21 additions & 0 deletions .changeset/fix-schema-annotations-across-encodings.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 69 additions & 16 deletions packages/effect/src/internal/schema/toRepresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,50 @@ function annotationsField<A>(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<string, unknown> | 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<A extends Schema.Annotations.Annotations>(
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"]
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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":
Expand All @@ -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 {
Expand All @@ -299,7 +352,7 @@ function fromASTs(
}),
rest: ast.rest.map((ast) => recur(ast)),
checks,
...annotationsField(ast.annotations)
...annotationsField(annotations)
}
case "Objects":
return {
Expand All @@ -320,22 +373,22 @@ function fromASTs(
type: recur(index.type)
})),
checks,
...annotationsField(ast.annotations)
...annotationsField(annotations)
}
case "Union":
return {
_tag: "Union",
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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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.passthroughSubtype<unknown, Date>())
).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" })

Expand Down
Loading