Found while fixing allOf catch-all handling in #49. Pre-existing, and not introduced by that PR — it reproduces on canary too.
Repro
components:
schemas:
Parent:
type: object
properties: { id: { type: string } }
additionalProperties: true
Child:
allOf:
- $ref: "#/components/schemas/Parent"
- type: object
properties: { name: { type: string } }
additionalProperties: true
var c Child
json.Unmarshal([]byte(`{"id":"x","name":"n","extra":"e"}`), &c)
// c.Name == nil
// c.Parent.AdditionalProperties == map[extra:e name:n]
name is a declared property with a typed field, and it silently lands in the parent's catch-all map instead. A round trip looks lossless — {"id":"x","extra":"e","name":"n"} comes back out — so the bug hides from any test that only checks the JSON.
Cause
Parent has additionalProperties, so it gets MarshalJSON/UnmarshalJSON. Child embeds Parent, so Go promotes those methods onto Child. json.Unmarshal into a Child therefore runs Parent's unmarshaler, which knows only Parent's fields and sweeps everything else — including name — into Parent.AdditionalProperties.
The generated marshalers reach encoding/json via type shadow T, but a shadow still embeds Parent and so still promotes its methods. Giving Child its own catch-all does not help: json.Marshal(shadow(t)) hits the same promoted method.
Because of that, #49 deliberately does not give a catch-all to a struct whose embedded type already has one (dropShadowedCatchAlls). That keeps the pre-existing behavior for this shape rather than adding a second catch-all that would fight the first — it does not fix the swallowing.
Fix
Replace the shadow trick for structs with embedded fields. Either flatten the embedded type's fields into the shadow — shadowing each embedded type too, so no methods are promoted:
type embedded0 Parent
type shadow struct {
embedded0
Name *string `json:"name,omitempty"`
}
(encoding/json still promotes the exported fields of an embedded unexported struct type, so the wire shape is unchanged.)
Or generate explicit field-by-field marshaling.
Note the condition for generating marshalers has to widen too: a struct that embeds a catch-all-bearing type needs its own marshalers even when it has no catch-all of its own, otherwise the promotion still happens.
Also worth fixing alongside
Method promotion here is inconsistent in a way that will bite: with two embedded types that both have MarshalJSON, the selector is ambiguous and no method is promoted at all — so behavior flips based on how many parents a schema composes.
Tests
- Decode into the composed type and assert the typed field is populated, not just that the JSON round-trips.
- A composed schema with two catch-all parents.
- Assert declared and inherited property names are emitted exactly once.
Found while fixing
allOfcatch-all handling in #49. Pre-existing, and not introduced by that PR — it reproduces oncanarytoo.Repro
nameis a declared property with a typed field, and it silently lands in the parent's catch-all map instead. A round trip looks lossless —{"id":"x","extra":"e","name":"n"}comes back out — so the bug hides from any test that only checks the JSON.Cause
ParenthasadditionalProperties, so it getsMarshalJSON/UnmarshalJSON.ChildembedsParent, so Go promotes those methods ontoChild.json.Unmarshalinto aChildtherefore runsParent's unmarshaler, which knows onlyParent's fields and sweeps everything else — includingname— intoParent.AdditionalProperties.The generated marshalers reach
encoding/jsonviatype shadow T, but a shadow still embedsParentand so still promotes its methods. GivingChildits own catch-all does not help:json.Marshal(shadow(t))hits the same promoted method.Because of that, #49 deliberately does not give a catch-all to a struct whose embedded type already has one (
dropShadowedCatchAlls). That keeps the pre-existing behavior for this shape rather than adding a second catch-all that would fight the first — it does not fix the swallowing.Fix
Replace the shadow trick for structs with embedded fields. Either flatten the embedded type's fields into the shadow — shadowing each embedded type too, so no methods are promoted:
(
encoding/jsonstill promotes the exported fields of an embedded unexported struct type, so the wire shape is unchanged.)Or generate explicit field-by-field marshaling.
Note the condition for generating marshalers has to widen too: a struct that embeds a catch-all-bearing type needs its own marshalers even when it has no catch-all of its own, otherwise the promotion still happens.
Also worth fixing alongside
Method promotion here is inconsistent in a way that will bite: with two embedded types that both have
MarshalJSON, the selector is ambiguous and no method is promoted at all — so behavior flips based on how many parents a schema composes.Tests