Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ func main() {
}
```

### Discriminated unions

A `oneOf`/`anyOf` with a `discriminator` generates a wrapper whose `Value` holds
the decoded variant. A discriminator value the client does not know is **not** an
error: `Value` stays nil, the original JSON is kept, and re-marshaling returns it
unchanged. Adding a variant server-side therefore stays backward compatible, and
one unrecognized element does not fail the payload it appears in.

```go
for _, shape := range shapes {
if shape.IsUnknownVariant() {
log.Printf("skipping unsupported shape %q", shape.UnknownDiscriminator())
continue // shape.Raw() still holds the original JSON
}
switch v := shape.Value.(type) {
case petstore.Circle:
...
}
}
```

A union *without* a discriminator still fails when no variant matches, since there
is nothing to identify the payload by.

## License

[MIT](LICENSE) — Copyright (c) 2026 Parallel Works
43 changes: 40 additions & 3 deletions internal/generator/e2e_inline_union_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,46 @@ func TestUnionValueTypeSwitch(t *testing.T) {
t.Errorf("marshal lost the discriminator: %s", out)
}

var bad ShapeCollectionShapesValue
if err := json.Unmarshal([]byte(` + "`" + `{"shapeType":"hexagon"}` + "`" + `), &bad); err == nil {
t.Fatal("expected an error for an unknown shapeType, got nil")
var unknown ShapeCollectionShapesValue
if err := json.Unmarshal([]byte(` + "`" + `{"shapeType":"hexagon","sides":6}` + "`" + `), &unknown); err != nil {
t.Fatalf("unknown shapeType should decode, got: %v", err)
}
if !unknown.IsUnknownVariant() {
t.Error("expected IsUnknownVariant for an unmapped shapeType")
}
if unknown.Value != nil {
t.Errorf("Value = %v, want nil for an unknown variant", unknown.Value)
}
if unknown.UnknownDiscriminator() != "hexagon" {
t.Errorf("UnknownDiscriminator() = %q, want hexagon", unknown.UnknownDiscriminator())
}

back, err := json.Marshal(unknown)
if err != nil {
t.Fatalf("marshal unknown: %v", err)
}
if !strings.Contains(string(back), ` + "`" + `"sides":6` + "`" + `) {
t.Errorf("re-marshal lost the unknown variant's payload: %s", back)
}
}

func TestUnknownVariantDoesNotFailSiblings(t *testing.T) {
payload := []byte(` + "`" + `{
"shapes": {
"a": {"shapeType": "circle", "radius": 2.5},
"b": {"shapeType": "triangle", "base": 2, "height": 3}
}
}` + "`" + `)

var sc ShapeCollection
if err := json.Unmarshal(payload, &sc); err != nil {
t.Fatalf("one unknown variant failed the whole decode: %v", err)
}
if _, ok := sc.Shapes["a"].Value.(Circle); !ok {
t.Fatalf("shapes[a].Value = %T, want Circle", sc.Shapes["a"].Value)
}
if !sc.Shapes["b"].IsUnknownVariant() {
t.Error("shapes[b] should be an unknown variant")
}
}
`)
Expand Down
39 changes: 37 additions & 2 deletions internal/templates/types.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,38 @@ const (
// Variants: {{ range $i, $v := .UnionTypes }}{{ if $i }}, {{ end }}{{ $v.TypeName }}{{ end }}
type {{ .Name }} struct {
Value any
{{- if .Discriminator }}

unknownDiscriminator string
raw json.RawMessage
{{- end }}
}
{{ if .Discriminator }}
// IsUnknownVariant reports whether the payload carried a {{ .Discriminator.PropertyName }}
// this client does not know. Value is nil in that case; the original JSON is
// available from Raw and is re-marshaled unchanged.
func (u {{ .Name }}) IsUnknownVariant() bool {
return u.Value == nil && len(u.raw) > 0
}

// UnknownDiscriminator returns the unrecognized {{ .Discriminator.PropertyName }} value,
// or "" when the payload decoded into a known variant.
func (u {{ .Name }}) UnknownDiscriminator() string {
return u.unknownDiscriminator
}

// Raw returns the original JSON of an unrecognized variant, or nil.
func (u {{ .Name }}) Raw() json.RawMessage {
return u.raw
}
{{ end }}
// MarshalJSON implements json.Marshaler for {{ .Name }}.
func (u {{ .Name }}) MarshalJSON() ([]byte, error) {
{{- if .Discriminator }}
if u.IsUnknownVariant() {
return u.raw, nil
}
{{- end }}
return json.Marshal(u.Value)
}

Expand All @@ -54,11 +82,18 @@ func (u *{{ $typeName }}) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &v); err != nil {
return err
}
u.Value = v
*u = {{ $typeName }}{Value: v}
return nil
{{- end }}
default:
return fmt.Errorf("unknown {{ .Discriminator.PropertyName }} value: %q", disc.{{ discriminatorFieldName .Discriminator.PropertyName }})
// Adding a variant to a oneOf is meant to be a backward-compatible change,
// so an unrecognized one is preserved verbatim instead of failing the decode
// of the whole payload it happens to appear in.
*u = {{ $typeName }}{
unknownDiscriminator: disc.{{ discriminatorFieldName .Discriminator.PropertyName }},
raw: append(json.RawMessage(nil), data...),
}
return nil
}
{{- else }}
var errors []error
Expand Down