Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.

Commit fe3e7e6

Browse files
mromaszewiczclaude
andcommitted
experimental: handle oneOf/anyOf nullable $ref pattern as Nullable[T]
Closes #19 Detect the OpenAPI 3.1 nullable $ref pattern — oneOf/anyOf with a $ref and {type: "null"} — and generate Nullable[T] instead of a union struct. This is the standard way to express nullable referenced types in 3.1 since $ref cannot be combined with type arrays. Skip gathering inline nullable aggregate schemas during the schema collection phase, eliminating unnecessary type aliases like NullableRefOneOfNullableObject that were generated alongside the correct Nullable[T] struct fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 277ace9 commit fe3e7e6

5 files changed

Lines changed: 200 additions & 79 deletions

File tree

experimental/codegen/internal/gather.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,14 +376,17 @@ func (g *gatherer) gatherFromSchemaProxy(proxy *base.SchemaProxy, path SchemaPat
376376
// References are always gathered (they point to real schemas)
377377
// Simple types (primitives without enum) are skipped for inline schemas
378378
// Inline nullable primitives (under properties/) don't need types - they use Nullable[T] directly
379+
// Inline nullable aggregates (oneOf/anyOf: [$ref, {type: null}]) also use Nullable[T] directly
379380
// Schemas with type-override or type-name-override extensions always need types
380381
// Component schemas (components/schemas/*) always get a type alias, even for primitives,
381382
// so that external packages can reference them by name.
382383
isComponentSchema := len(path) == 3 && path[0] == "components" && path[1] == "schemas"
383384
isInlineProperty := path.ContainsProperties()
384385
skipInlineNullablePrimitive := isInlineProperty && isNullablePrimitive(schema)
386+
isNullableAgg, _ := isNullableAggregate(schema)
387+
skipInlineNullableAggregate := isInlineProperty && isNullableAgg
385388
needsType := isRef || needsGeneratedType(schema) || hasTypeOverride || hasTypeNameOverride || isComponentSchema
386-
if needsType && !skipInlineNullablePrimitive {
389+
if needsType && !skipInlineNullablePrimitive && !skipInlineNullableAggregate {
387390
desc := &SchemaDescriptor{
388391
Path: path,
389392
Parent: parent,
@@ -525,6 +528,33 @@ func needsGeneratedType(schema *base.Schema) bool {
525528
return false
526529
}
527530

531+
// isNullableAggregate detects the OpenAPI 3.1 pattern for nullable $ref types:
532+
// oneOf/anyOf with exactly two members where one is {type: "null"}.
533+
// Since you cannot combine $ref with a type array, this is the standard way to
534+
// make a referenced type nullable. Returns the non-null member's schema proxy
535+
// if matched.
536+
func isNullableAggregate(schema *base.Schema) (bool, *base.SchemaProxy) {
537+
if schema == nil {
538+
return false, nil
539+
}
540+
541+
var members []*base.SchemaProxy
542+
if len(schema.OneOf) == 2 {
543+
members = schema.OneOf
544+
} else if len(schema.AnyOf) == 2 {
545+
members = schema.AnyOf
546+
} else {
547+
return false, nil
548+
}
549+
550+
if s := members[0].Schema(); s != nil && len(s.Type) == 1 && s.Type[0] == "null" {
551+
return true, members[1]
552+
} else if s := members[1].Schema(); s != nil && len(s.Type) == 1 && s.Type[0] == "null" {
553+
return true, members[0]
554+
}
555+
return false, nil
556+
}
557+
528558
// isNullablePrimitive returns true if the schema is a nullable primitive type.
529559
// Nullable primitives need Nullable[T] wrapper types.
530560
func isNullablePrimitive(schema *base.Schema) bool {

experimental/codegen/internal/test/comprehensive/output/comprehensive.gen.go

Lines changed: 68 additions & 78 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package output
2+
3+
import "testing"
4+
5+
// TestNullableRefOneOf verifies that oneOf: [$ref, {type: "null"}] generates
6+
// Nullable[T] instead of a full union struct. This is the standard OpenAPI 3.1
7+
// pattern for making a $ref'd type nullable.
8+
// https://github.com/oapi-codegen/oapi-codegen-exp/issues/19
9+
func TestNullableRefOneOf(t *testing.T) {
10+
obj := NullableRefOneOf{
11+
NullableObject: NewNullableWithValue(SimpleObject{ID: 1}),
12+
}
13+
val := obj.NullableObject.MustGet()
14+
if val.ID != 1 {
15+
t.Errorf("NullableObject.ID = %d, want 1", val.ID)
16+
}
17+
18+
// Test null state
19+
obj.NullableObject.SetNull()
20+
if !obj.NullableObject.IsNull() {
21+
t.Error("NullableObject should be null after SetNull()")
22+
}
23+
24+
// Optional nullable ref should also be Nullable[T] (not *Nullable[T]),
25+
// because Nullable already handles the "unspecified" state.
26+
obj2 := NullableRefOneOf{
27+
NullableObject: NewNullableWithValue(SimpleObject{ID: 2}),
28+
NullableObjectOptional: NewNullableWithValue(SimpleObject{ID: 3}),
29+
}
30+
_ = obj2
31+
}
32+
33+
// TestNullableRefAnyOf verifies the same pattern using anyOf instead of oneOf.
34+
func TestNullableRefAnyOf(t *testing.T) {
35+
obj := NullableRefAnyOf{
36+
NullableObject: NewNullableWithValue(SimpleObject{ID: 1}),
37+
}
38+
val := obj.NullableObject.MustGet()
39+
if val.ID != 1 {
40+
t.Errorf("NullableObject.ID = %d, want 1", val.ID)
41+
}
42+
}

experimental/codegen/internal/test/files/comprehensive.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,32 @@ components:
278278
- integer
279279
- "null"
280280

281+
# OpenAPI 3.1 nullable $ref via oneOf/anyOf — the only way to make
282+
# a referenced type nullable since you cannot combine $ref with type arrays.
283+
NullableRefOneOf:
284+
type: object
285+
required:
286+
- nullableObject
287+
properties:
288+
nullableObject:
289+
oneOf:
290+
- $ref: "#/components/schemas/SimpleObject"
291+
- type: "null"
292+
nullableObjectOptional:
293+
oneOf:
294+
- $ref: "#/components/schemas/SimpleObject"
295+
- type: "null"
296+
297+
NullableRefAnyOf:
298+
type: object
299+
required:
300+
- nullableObject
301+
properties:
302+
nullableObject:
303+
anyOf:
304+
- $ref: "#/components/schemas/SimpleObject"
305+
- type: "null"
306+
281307
# ===========================================
282308
# ARRAYS
283309
# ===========================================

experimental/codegen/internal/typegen.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,19 @@ func (g *TypeGenerator) goTypeForSchema(schema *base.Schema, desc *SchemaDescrip
242242
if len(schema.AllOf) > 0 {
243243
return g.allOfType(desc)
244244
}
245+
246+
// Check for nullable oneOf/anyOf pattern: [realType, {type: "null"}]
247+
// This is the OpenAPI 3.1 way to make a $ref'd type nullable.
248+
if ok, nonNullProxy := isNullableAggregate(schema); ok {
249+
if baseType := g.resolveNullableOneOfType(nonNullProxy); baseType != "" {
250+
if g.ctx.RuntimeTypesPrefix() != "" {
251+
return g.ctx.RuntimeTypesPrefix() + "Nullable[" + baseType + "]"
252+
}
253+
g.AddNullableTemplate()
254+
return "Nullable[" + baseType + "]"
255+
}
256+
}
257+
245258
if len(schema.AnyOf) > 0 {
246259
return g.anyOfType(desc)
247260
}
@@ -462,6 +475,20 @@ func (g *TypeGenerator) oneOfType(desc *SchemaDescriptor) string {
462475
return "any"
463476
}
464477

478+
// resolveNullableOneOfType resolves the Go type for the non-null member of a
479+
// nullable oneOf/anyOf pattern. Returns empty string if it can't be resolved.
480+
func (g *TypeGenerator) resolveNullableOneOfType(nonNullProxy *base.SchemaProxy) string {
481+
if nonNullProxy.IsReference() {
482+
ref := nonNullProxy.GetReference()
483+
if target, ok := g.schemaIndex[ref]; ok {
484+
return target.ShortName
485+
}
486+
return ""
487+
}
488+
// Inline non-null member — resolve its type directly
489+
return g.goTypeForSchema(nonNullProxy.Schema(), nil)
490+
}
491+
465492
// StructField represents a field in a generated Go struct.
466493
type StructField struct {
467494
Name string // Go field name
@@ -923,6 +950,12 @@ func GetSchemaKind(desc *SchemaDescriptor) SchemaKind {
923950
if len(schema.AllOf) > 0 {
924951
return KindAllOf
925952
}
953+
954+
// Nullable oneOf/anyOf pattern: [{$ref/type}, {type: "null"}] → alias to Nullable[T]
955+
if ok, _ := isNullableAggregate(schema); ok {
956+
return KindAlias
957+
}
958+
926959
if len(schema.AnyOf) > 0 {
927960
return KindAnyOf
928961
}

0 commit comments

Comments
 (0)