Skip to content

fix(schema): share representation identity across rebuilt containers of one declaration - #6838

Closed
aryasaatvik wants to merge 2 commits into
Effect-TS:mainfrom
aryasaatvik:fix/representation-structural-containers
Closed

fix(schema): share representation identity across rebuilt containers of one declaration#6838
aryasaatvik wants to merge 2 commits into
Effect-TS:mainfrom
aryasaatvik:fix/representation-structural-containers

Conversation

@aryasaatvik

Copy link
Copy Markdown

Stacked on #6837 — the branch includes its commit; only the last commit (Share representation identity across rebuilt containers of one declaration) is new here. The two are companion halves of one invariant: #6837 stops HttpApiSchema.status from forking the annotations slot; this stops derivation rebuilds from forking the container slots.

Problem

SchemaRepresentation resolves component identity by reference: an AST's identity key is the
composition of the object identities of its slots, with context excluded. That works as long as
every reuse of a declared schema reaches the representation as reference-shared structure.

Memoized derivations break that assumption whenever the derivation is non-identity. toCodecJson
lowers Unknown to Json by rewriting the child node, which forces the parent rebuild to allocate
fresh PropertySignature wrappers and a fresh propertySignatures array — once per top-level
derivation input. A named schema and its context-only copy (HttpApiSchema.status after #6837) are
two derivation inputs, so their rebuilt containers can never be reference-equal, and the identity
key diverges at exactly that slot:

const Widget = Schema.Struct({
  id: Schema.String,
  metadata: Schema.Record(Schema.String, Schema.Unknown)
}).annotate({ identifier: "Widget" })

HttpApiEndpoint.get("get", "/widgets/:id", { params: { id: Schema.String }, success: Widget })
HttpApiEndpoint.post("create", "/widgets", { success: Widget.pipe(HttpApiSchema.status(201)) })

OpenApi.fromApi(Api).components.schemas
// { Widget: …, Widget_1: … } — two identical components for one declared name
// (on 4.0.0-beta.102 this is a `Duplicate identifier: "Widget"` throw instead)

Remove the metadata field and the pair unifies — the failure is keyed to whether any child's
derivation is non-identity, which makes it look nondeterministic from the API author's seat. Any
schema carrying Schema.Unknown (a JSON metadata column, a Record(String, Unknown)) hits it in
the default CRUD shape.

Fix

Container wrappers — propertySignatures / indexSignatures arrays, PropertySignature,
IndexSignature — are pure structure: they carry no annotations, checks, or context of their own,
so they have no identity to defend. When two ASTs hold the same annotations object — the token
of one user declaration — their container slots now compare by contents (element-wise reference
identity) instead of by container reference.

The gate is what keeps existing semantics intact:

  • Anonymous nodes keep pure reference identity — "does not extract structurally equivalent schemas
    with distinct ASTs" is unchanged.
  • Two .annotate({ identifier: "Value" }) calls create two annotations objects, so referentially
    distinct declarations still suffix (Value / Value_1).
  • Two composites sharing a declaration but with genuinely different recursive dependencies still
    split: their containers differ at the child references themselves ("does not deduplicate fallback
    definitions with distinct recursive dependencies" is unchanged).

Scope: one function in internal/schema/toRepresentation.ts; no AST or derivation changes.

Tests

  • toRepresentations.test.ts: "shares a named reference across context-only copies whose
    derivation rebuilds a container" — pure representation-level regression, fails with
    ["Widget", "Widget_1"] refs before the fix.
  • OpenApi.test.ts: "emits one component for a named schema containing Unknown reused with
    HttpApiSchema.status" — end-to-end CRUD shape, both 200 and 201 referencing one Widget
    component. Depends on fix(httpapi): reuse named schemas across response statuses #6837 (without it the copies also differ at the annotations slot).

packages/effect/test/schema + packages/effect/test/unstable/httpapi: 2220 tests green;
tsc -b packages/effect and lint clean.

HttpApiSchema.status annotated the schema with httpApiStatus, forking the
AST while the fork kept its identifier. A named schema reused with a
non-default status then published duplicate OpenAPI components for one
declared name (previously: Duplicate identifier throw).

The status describes how one endpoint uses the schema, not what the
schema is, so it now rides the AST key context — which representation
identity already excludes — via annotateKey. Declaration-site statuses
(the HttpApiError classes) keep working through the annotation fallback.
…ation

A memoized AST derivation (toCodecJson) rebuilds a node whenever any child
changes - e.g. Unknown lowering to Json - allocating fresh property-signature
wrappers and arrays per top-level input. Two ASTs that differ only in their
key context (a named schema and its HttpApiSchema.status copy) are separate
derivation inputs, so their rebuilt containers can never be reference-equal
and representation identity split them into Widget and Widget_1.

Container wrappers are pure structure with no identity of their own. When two
ASTs hold the same annotations object - the token of one user declaration -
their container slots now compare by contents instead of by reference.
Anonymous nodes and nodes with different annotation objects keep pure
reference identity, so referentially distinct declarations still suffix.
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 571d0c7

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/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue 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/docgen Patch
@effect/doctest Patch
@effect/openapi-generator 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

@effect-slopcop effect-slopcop Bot added 4.0 bug Something isn't working labels Aug 1, 2026
@aryasaatvik

Copy link
Copy Markdown
Author

Consolidated into #6837 — that PR now carries both commits (the httpapi key-context fix and this representation-identity fix) as companion halves of one invariant.

@aryasaatvik aryasaatvik closed this Aug 1, 2026
@github-project-automation github-project-automation Bot moved this from Discussion Ongoing to Done in PR Backlog Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 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.63 KB 6.63 KB 0.00 KB (0.00%)
batching.ts 9.42 KB 9.42 KB 0.00 KB (0.00%)
brand.ts 6.31 KB 6.31 KB 0.00 KB (0.00%)
cache.ts 10.16 KB 10.16 KB 0.00 KB (0.00%)
config.ts 20.32 KB 20.32 KB 0.00 KB (0.00%)
differ.ts 19.93 KB 19.93 KB 0.00 KB (0.00%)
http-client.ts 21.02 KB 21.02 KB 0.00 KB (0.00%)
logger.ts 10.32 KB 10.32 KB 0.00 KB (0.00%)
metric.ts 8.55 KB 8.55 KB 0.00 KB (0.00%)
optic.ts 7.33 KB 7.33 KB 0.00 KB (0.00%)
pubsub.ts 14.46 KB 14.46 KB 0.00 KB (0.00%)
queue.ts 11.13 KB 11.13 KB 0.00 KB (0.00%)
schedule.ts 10.31 KB 10.31 KB 0.00 KB (0.00%)
schema-class.ts 18.86 KB 18.86 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 28.67 KB 28.67 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.10 KB 24.99 KB +0.11 KB (+0.45%)
schema-string-transformation.ts 12.99 KB 12.99 KB 0.00 KB (0.00%)
schema-string.ts 10.65 KB 10.65 KB 0.00 KB (0.00%)
schema-template-literal.ts 14.85 KB 14.85 KB 0.00 KB (0.00%)
schema-toArbitraryLazy.ts 21.66 KB 21.66 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.18 KB 24.08 KB +0.11 KB (+0.44%)
schema-toCodecJson.ts 18.91 KB 18.91 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.73 KB 18.73 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.59 KB 18.59 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.45 KB 22.33 KB +0.12 KB (+0.52%)
schema-toRepresentation.ts 19.36 KB 19.25 KB +0.11 KB (+0.58%)
schema.ts 18.12 KB 18.12 KB 0.00 KB (0.00%)
stm.ts 12.11 KB 12.11 KB 0.00 KB (0.00%)
stream.ts 9.37 KB 9.37 KB 0.00 KB (0.00%)

@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 found.

Reviewed changes — two commits that fix duplicate OpenAPI component generation when a declared schema is reused with HttpApiSchema.status().

  • HttpApiSchema.status switched to annotateKey: Stores httpApiStatus in key context (excluded from representation identity) instead of annotations, with an annotation fallback for HttpApiError classes.
  • toRepresentation.ts structural slot comparison: When two ASTs share the same annotations object, container slots (PropertySignature, IndexSignature, arrays) compare structurally rather than by reference, so derivation rebuilds (e.g. toCodecJson of Unknown → Json) don't fork identity.
  • Tests: One regression test at the representation level, two end-to-end OpenAPI tests. All existing tests pass — invariants for anonymous nodes, referentially distinct declarations, and recursive fallback definitions are preserved.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) (Kimi K2 not used — the program covers this model; add its provider key to run your pick) | 𝕏

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

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant