Motivation
oapi-codegen/oapi-codegen#2521 / oapi-codegen/oapi-codegen#2522 map OpenAPI 3.1 multi-type unions (type: [string, integer]) to any. That works for schema positions, but a union in a parameter position generates code that compiles and then fails on every request: the binder receives an *any destination and rejects it unconditionally:
error binding string parameter: can not bind to destination of type: interface
The binding machinery is destination-driven — it reflects on the Go type to decide how to parse — and an interface destination carries no information. The schema knew the answer ([string, integer]), but the runtime never sees it: BindStyledParameterOptions.Type / BindQueryParameterOptions.Type exist and are populated by generated code, yet nothing consumes them today (only Format == "byte" is load-bearing). A styled value like 123 is also genuinely ambiguous — URL serialization is lossy — so the runtime needs the member list to make a deterministic choice.
Proposal
Add one field to both options structs:
// Types is the OpenAPI type list of the parameter for 3.1 multi-type
// unions (e.g. ["string", "integer"]), with "null" stripped. When
// non-empty it takes precedence over Type. It is only consulted when
// the destination is an interface; concrete destinations keep the
// existing reflection-driven path unchanged.
Types []string
Binding semantics when Types is non-empty and the destination is an interface:
- Try members in specificity order —
boolean, integer, number, string — restricted to the members actually present. First successful parse wins.
- Deliberately not schema-declaration order: JSON Schema defines the
type array as an unordered set, and string always parses, so declaration order would make the string member swallow everything whenever it's listed first.
- Numeric detection uses JSON grammar, not
strconv leniency: 007, +1, 1 are not JSON numbers and fall through to string. This makes the result identical to what json.Unmarshal into any would produce for the same token, which is the documented mental model.
Format applies only to its host member and only where it changes decoding: byte base64-decodes the string member (yielding []byte); int32/int64/float/double select the numeric width. Annotation-only formats (date-time, uuid, …) are ignored in v1 — per 3.1 semantics format is an annotation and must not reject a value, so parse failure can't be a discriminator anyway. A format whose host type isn't in Types is silently inert (spec-legal).
array / object members are out of scope and keep failing — styled serialization of those into any has no sensible meaning.
- If no member parses (e.g.
Types: ["integer", "boolean"] and value abc), return a binding error naming the parameter and the member list.
Compatibility
- Adding a struct field is API-compatible; old generated code never sets it.
- Gating on interface destinations means no existing binding path changes behavior — concrete destinations are untouched even when
Types is set.
- Old runtime + new generated code fails at compile time (unknown field), the same coupling story as the recent
Type/Format/ValueIsUnescaped additions.
Codegen side (tracked in the main repo)
- Emit
Types for multi-type params, "null" stripped, mirroring schemaPrimaryType.
- Fix
ParameterDefinition.SchemaType(), which currently emits the first entry of the type list (Type: "string" for [string, integer], and would emit "null" for ["null", "string"]). Harmless while Type is unread; a landmine once type metadata becomes load-bearing.
- Client-side
StyleParamWithOptions serialization of an any-valued union param needs a round-trip test in the same change.
Explicitly not this issue
A fully schema-driven binder (runtime honoring declared types over destination reflection everywhere) was considered and rejected for this repo: destination types already encode the schema's answer in the common case, type-mapping/x-go-type deliberately decouple the two and rely on reflection, and version skew across years of generated code means it could only ever be an opt-in second path. If Types+Format later prove insufficient (arrays of unions, explode ambiguities, deepObject), the natural promotion is a small runtime-owned ParamSchema{Types, Format, Items} descriptor on the same options structs — parser-agnostic, so it stays viable across kin-openapi and libopenapi — rather than accepting parser AST types into this module's API.
Motivation
oapi-codegen/oapi-codegen#2521 / oapi-codegen/oapi-codegen#2522 map OpenAPI 3.1 multi-type unions (
type: [string, integer]) toany. That works for schema positions, but a union in a parameter position generates code that compiles and then fails on every request: the binder receives an*anydestination and rejects it unconditionally:The binding machinery is destination-driven — it reflects on the Go type to decide how to parse — and an interface destination carries no information. The schema knew the answer (
[string, integer]), but the runtime never sees it:BindStyledParameterOptions.Type/BindQueryParameterOptions.Typeexist and are populated by generated code, yet nothing consumes them today (onlyFormat == "byte"is load-bearing). A styled value like123is also genuinely ambiguous — URL serialization is lossy — so the runtime needs the member list to make a deterministic choice.Proposal
Add one field to both options structs:
Binding semantics when
Typesis non-empty and the destination is an interface:boolean,integer,number,string— restricted to the members actually present. First successful parse wins.typearray as an unordered set, andstringalways parses, so declaration order would make the string member swallow everything whenever it's listed first.strconvleniency:007,+1,1are not JSON numbers and fall through tostring. This makes the result identical to whatjson.Unmarshalintoanywould produce for the same token, which is the documented mental model.Formatapplies only to its host member and only where it changes decoding:bytebase64-decodes the string member (yielding[]byte);int32/int64/float/doubleselect the numeric width. Annotation-only formats (date-time,uuid, …) are ignored in v1 — per 3.1 semanticsformatis an annotation and must not reject a value, so parse failure can't be a discriminator anyway. A format whose host type isn't inTypesis silently inert (spec-legal).array/objectmembers are out of scope and keep failing — styled serialization of those intoanyhas no sensible meaning.Types: ["integer", "boolean"]and valueabc), return a binding error naming the parameter and the member list.Compatibility
Typesis set.Type/Format/ValueIsUnescapedadditions.Codegen side (tracked in the main repo)
Typesfor multi-type params,"null"stripped, mirroringschemaPrimaryType.ParameterDefinition.SchemaType(), which currently emits the first entry of the type list (Type: "string"for[string, integer], and would emit"null"for["null", "string"]). Harmless whileTypeis unread; a landmine once type metadata becomes load-bearing.StyleParamWithOptionsserialization of anany-valued union param needs a round-trip test in the same change.Explicitly not this issue
A fully schema-driven binder (runtime honoring declared types over destination reflection everywhere) was considered and rejected for this repo: destination types already encode the schema's answer in the common case,
type-mapping/x-go-typedeliberately decouple the two and rely on reflection, and version skew across years of generated code means it could only ever be an opt-in second path. IfTypes+Formatlater prove insufficient (arrays of unions, explode ambiguities, deepObject), the natural promotion is a small runtime-ownedParamSchema{Types, Format, Items}descriptor on the same options structs — parser-agnostic, so it stays viable across kin-openapi and libopenapi — rather than accepting parser AST types into this module's API.