Redesign OneOf unions as first-class sum types #3866
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Redesigning
OneOfUnions as First-Class Sum TypesOverview
This change overhauls how Goa represents and transports
OneOfunions.Instead of interface-based unions with helper transport objects, Goa now
generates a first-class sum-type struct per union with a discriminator
field, strongly-typed branches, and custom JSON marshaling.
At a high level this delivers:
well-defined API (
Kind,NewXxx,AsXxx,Validate).{ "type": "...", "value": <native JSON> }shape.oneOfand protobufoneofnow map more directly to the generated Go types.transform templates is removed in favor of a single, explicit model.
This document focuses on why this redesign was made and the
benefits it unlocks, independent of any particular application.
Background: The Old Interface-Based Unions
Historically, Goa modeled unions as interfaces with marker methods
plus helper transport objects:
Ubecame an interface implemented by per-branch wrappertypes (e.g.
type U interface{ uVal() },type UString struct{...}).{Type,Value string}helper object, whereValueheld JSON-encoded data as a string.GoTransformhad dedicated templates to shuttle between the interfaceand this helper object for HTTP and JSON-RPC.
This worked, but it had clear drawbacks:
New Model: Closed Sum-Type Structs
Unions are now generated as closed sum types:
Ubecomes a concrete struct:kindfield of enum-like typeUKind.A,B,String,Int, etc.).NewUA(...) U.AsA() (T, bool).Validate() errorto check the discriminant.MarshalJSON/UnmarshalJSONusing a canonical{type,value}representation.Conceptually, this moves unions much closer to:
oneof,oneOfwith an explicit discriminator.The type is closed: only the generated branches are valid, and
the discriminator is always one of the known tags (or empty).
Why This Is Better (Conceptually and Practically)
1. First-Class Sum Types in Go
The Go representation is now explicitly a sum type:
kind), andNewUnionFoo(v)to construct a value in theFoobranch.AsFoo()to safely read theFoobranch.Benefits:
the type does not know about.
affects one struct instead of an interface plus multiple wrappers.
2. Canonical, Human-Friendly JSON
All unions use the same clear JSON shape:
{ "type": "foo", "value": <native JSON> }where:
typeis the discriminator string for the chosen branch.valueis the native JSON representation of the branch value(number, string, object, array, etc.).
Benefits:
payloads, not nested escape sequences.
independent of where it appears (request payload, response,
nested object).
contract without knowing about internal helper types or special
transform rules.
3. Alignment With OpenAPI
oneOfand ProtobufoneofThe new model aligns Goa internal representation and transports
with existing standards:
oneOfcan be expressed as an object withtype(discriminator)and
value(payload) and a clear mapping from union tags toschemas.
oneofbranches naturally map to the union branch fields.are explicit and straightforward.
Benefits:
types reflect the same conceptual model.
libraries can rely on well-known patterns (oneOf/oneof + discriminator)
instead of Goa-specific conventions.
4. Simpler, More Robust Code Generation
By centralizing the union logic into a single sum-type struct with
custom JSON methods, several layers of complexity vanish:
{Type,Value}helper graphs.helper objects.
GoTransformonly needs to handle union-to-union transforms;other cases fail clearly instead of relying on hidden helpers.
Benefits:
fewer cross-cutting hacks.
richer discriminators, or better OpenAPI annotations becomes more
straightforward.
codegen rather than being massaged by transport-specific helpers.
5. Clearer Transport Semantics
The redesign deliberately constrains where unions can appear:
cookies, path parameters, and form data.
This is a principled choice:
sum type with discriminated variants and nested JSON.
consumers.
Benefits:
represented cleanly, the DSL validation fails early with a clear
message.
(JSON bodies), and other transports lean on simpler types that are
easier to consume.
Migration
This redesign is intentionally breaking in a few places. This section
describes the main cases and how to migrate.
1. Go code using the old union interfaces and wrapper types
Previously, unions were exposed as interfaces with per-branch wrapper
types and marker methods. You are affected if you:
interfaces (e.g.
Valuesas an interface).ValuesString,ValuesInt,etc.) or call marker methods (
valuesVal,myUnionVal, …).How to migrate:
NewUnionFoo(v)to build a union in a given branch.AsFoo()to read a branch, combined withKind()ifyou want a
switchon the discriminator.field type of the sum struct.
2. JSON / HTTP / JSON-RPC clients using
{Type,Value string}Clients and tools that previously sent or expected:
{ "Type": "foo", "Value": "{\"x\": 1}" }must now speak the canonical shape:
{ "type": "foo", "value": { "x": 1 } }How to migrate:
type/valuefield names, andvalueas native JSON (numbers, strings, objects, arrays),not as a JSON string.
{type,value}shape, or{Type,Value string}form and rewrites it into the sum-typerepresentation before handing it to generated code.
3. Unions used outside JSON bodies
Unions are now only supported in JSON bodies (HTTP and JSON-RPC).
If a union appears in headers, query parameters, path parameters,
cookies, or form data, the DSL now rejects the design.
How to migrate:
domain.
OneOfonly on attributes that map to JSON bodies.4. Custom templates or tooling relying on union helper transforms
Custom codegen that called
GoTransformwith a union on one side and a{Type,Value}helper object on the other is no longer supported.GoTransformnow expects union-to-union transforms; other casesfail with a descriptive error.
How to migrate:
MarshalJSON/UnmarshalJSONinstead of custom transforms to helper objects.
and order of branches) or write explicit code in terms of the sum-type
struct (
Kind,AsXxx,NewXxx).What Users Gain
From a user perspective, this redesign yields:
than an interface plus multiple hidden wrappers.
{ "type": "...", "value": ... }is self-explanatory and worksacross languages and platforms.
discriminants are caught and reported clearly.
OneOfto Go, HTTP, OpenAPI, and gRPC isuniform and documented, instead of split across several helper
layers.
Overall, this change trades some short-term migration work for a
long-term model that is simpler, more principled, and easier to
build on for all services using Goa unions.