Skip to content

feat(analyzer): a union parameter names a type instead of resting at any - #88

Merged
giraffesyo merged 1 commit into
canaryfrom
union-params
Aug 20, 2026
Merged

feat(analyzer): a union parameter names a type instead of resting at any#88
giraffesyo merged 1 commit into
canaryfrom
union-params

Conversation

@giraffesyo

Copy link
Copy Markdown
Member

Closes #87.

- name: either
  in: query
  schema:
    anyOf: [{ type: string }, { type: integer }]
type ListItemsParams struct {
	Either *any `json:"either,omitempty"`   // before
}

Assignable, since everything satisfies any, but nothing in the generated package said what was valid. This is the tail of #16.

Why it was blocked, and what unblocked it

#82 deliberately withheld the name hint here. Synthesizing the type is easy; the problem is that a union is a struct, and the query encoder writes a struct as an object, so either=42 would have gone out as the wrapper's shape. Typing the field would have made the wire wrong.

Every generated union now answers an unexported marker:

func (u ListItemsEither) unionValue() any { return u.Value }

and derefParam, which every parameter encoder already goes through, unwraps it:

if u, ok := rv.Interface().(unionValuer); ok {
	return derefParam(u.unionValue())
}

One place, so query, header, path, and cookie parameters all benefit. An unexported method rather than sniffing for a struct with a field named Value, which a schema could legitimately declare.

With the wire correct, the analyzer passes the hint:

either := ListItemsEither{Value: 42}
client.ListItems(ctx, ListItemsParams{Either: &either})   // ?either=42

Behavior worth knowing

  • Unwrapping happens before the style is applied, not instead of it, so a list variant still explodes and a deepObject object parameter still explodes.
  • A union carrying nothing sends no parameter, rather than a zero of some variant's type.
  • One shape is one type. Two parameters declaring the same union share it, so a header parameter reuses the type a query parameter named. That also means a path parameter's type can be named after the operation that declared the shape first: GetItem(ctx, id ListItemsEither). Existing behavior for inline unions, now visible in a signature.
  • Nullable unions are untouched: anyOf: [string, null] still collapses to *string.
  • Inline objects in parameters ride along, since they were hintless for the same reason, and still encode under their style.

Tests

  • internal/analyzer/operations_test.go: a union parameter names a union type, two parameters of one shape share it, a nullable union still collapses to string, and an inline object parameter is named.
  • internal/generator/e2e_union_param_test.go: compiles and runs against httptest, checking an integer variant arrives as 42 and a string variant as abc, a list variant still explodes, header and path parameters unwrap too, an empty union sends nothing, a deepObject inline object explodes into at[lat] and at[lon], and a plain parameter is unaffected.

One existing expectation changed: TestNullableUnion_QueryParamsResolveToTheVariantType asserted that a real choice stays any, which was the behavior this replaces.

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

A parameter with a genuine choice of types resolved to any: assignable,
since everything is, but nothing in the package said what was valid.

Typing it needed the encoders to stop writing the wrapper. Every generated
union now answers unionValue, and derefParam unwraps through it, so the
value the union carries goes on the wire under the parameter's own style: a
scalar as itself, a list still exploded, and a union carrying nothing sends
no parameter at all. The marker is an unexported method rather than a struct
shape sniff, which a schema declaring a property named value would trip.

With that in place the analyzer passes the name hint it had been withholding
for exactly this reason, so union and inline-object parameters are typed and
constructible.
@giraffesyo
giraffesyo merged commit 15fdb23 into canary Aug 20, 2026
7 checks passed
@giraffesyo
giraffesyo deleted the union-params branch August 20, 2026 21:56
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.

A union-typed parameter has no usable Go type, so it is left as any

1 participant