Skip to content

feat(generator): unions expose the base every variant composes - #57

Merged
giraffesyo merged 1 commit into
canaryfrom
union-shared-base
Aug 19, 2026
Merged

feat(generator): unions expose the base every variant composes#57
giraffesyo merged 1 commit into
canaryfrom
union-shared-base

Conversation

@giraffesyo

Copy link
Copy Markdown
Member

Closes #56.

Problem

When every branch of a discriminated oneOf composes the same schema via allOf, the branches come out right (the base is embedded in each), but the union discards that fact:

type Pet struct {
	Value any

	unknownDiscriminator string
	raw                  string
}

Reading a field that is present on every variant by construction costs a type switch, and the compiler cannot tell you when a new variant makes that switch incomplete.

Fix

The union now carries the shared base and generates an accessor for it:

// Base returns the PetBase that every variant of Pet composes, or
// nil when Value holds none of them. It is a copy: writing to it does not change
// the value the union holds.
func (u Pet) Base() *PetBase {
	switch v := u.Value.(type) {
	case Dog:
		return &v.PetBase
	case Cat:
		return &v.PetBase
	}
	return nil
}

Value is untouched, so existing call sites keep working.

  • ir.TypeDef.BaseType records, for a union, the type every variant embeds.
  • analyzer.linkUnionBases runs after cycle-breaking, so it sees each variant's final field shape, and sets BaseType only when the embedded type sets of all variants intersect in exactly one struct. Variants that share no base, share more than one, or reach it through a cycle-broken pointer keep today's shape.
  • The template emits the accessor; distinctVariants keeps the switch from repeating a case when a spec lists the same $ref twice.

A discriminator is not required. Value holds the same variant values whether or not the union has one, so an untagged oneOf with a shared base gets the accessor too.

Tests

  • internal/analyzer/unionbases_test.go: shared base, base only some variants compose, two shared bases, and a discriminator-free union.
  • internal/generator/e2e_union_base_test.go: generates the issue's spec, asserts a union without a common base gets no accessor, then compiles and runs a test that reads Base().Name off decoded values, checks nil for an unrecognized kind, and confirms writing through the returned pointer does not mutate Value.

gofmt, go vet ./..., and go test ./... pass.

When every arm of a oneOf/anyOf embeds the same schema through allOf, the
generated wrapper only offered Value any, so reading a field present on all
variants meant a type switch that goes stale as variants are added.

The union now carries that base and generates an accessor for it:

	func (u Pet) Base() *PetBase

The analyzer sets TypeDef.BaseType after cycle-breaking, when the embedded
type sets of all variants intersect in exactly one struct. Unions whose
variants share no base, share more than one, or reach it through a
cycle-broken pointer keep their current shape. A discriminator is not
required: Value holds the same variant values either way, and Base returns
nil when it holds none of them.
@giraffesyo
giraffesyo merged commit d9de8a1 into canary Aug 19, 2026
6 checks passed
@giraffesyo
giraffesyo deleted the union-shared-base branch August 19, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discriminated oneOf discards the allOf base shared by every branch

1 participant