From 88c2c79d2c24922e9895baf091de291bd479cd1b Mon Sep 17 00:00:00 2001 From: patrick thornton <70873350+patrickthornton@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:06:00 -0400 Subject: [PATCH 1/2] feat(go): add dedupeUnionBaseProperties flag for discriminated unions (#16554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(go): add dedupeUnionBaseProperties flag to drop duplicated union base properties When a discriminated union has base properties that every variant already carries (e.g. lifted from a shared parent by infer-discriminated-union-base-properties), the new opt-in dedupeUnionBaseProperties config (default false) suppresses the duplicate top-level fields and exposes them via discriminant-switching getters that read from the active variant. * fix(go): keep literal base properties out of union dedup A literal base property carried by every variant must not be suppressed: the union and each variant expose literals via a `()` getter (no `Get` prefix), so a delegating `Get()` would call a non-existent variant method and fail to compile. Exclude literals from the suppression set so they stay on the normal literal path. * fix(go): only dedupe union base properties when variant getters match Suppression previously matched base↔variant properties by name only and assumed each variant exposed a Get() returning the base property's type. A same-named variant property with a different type, or a literal (whose getter is () with no Get prefix), produced a delegating getter that did not compile. Now suppress a base property only when every variant declares a non-literal property of the same name whose getter type matches the base property's getter type; otherwise keep the top-level field. Type comparison runs against a throwaway scope so a variant's (unused) type never leaks in as an import. Also pass the precomputed set into getTypeFieldsForUnion instead of recomputing it. --- .../custom-config/BaseGoCustomConfigSchema.ts | 1 + generators/go/cmd/fern-go-model/main.go | 1 + generators/go/cmd/fern-go-sdk/main.go | 1 + generators/go/internal/cmd/cmd.go | 12 +- generators/go/internal/generator/config.go | 3 + .../go/internal/generator/file_writer.go | 5 + generators/go/internal/generator/generator.go | 16 ++ generators/go/internal/generator/model.go | 180 ++++++++++++++- .../go/internal/generator/model_union_test.go | 206 ++++++++++++++++++ ...ppress-duplicate-union-base-properties.yml | 14 ++ 10 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 generators/go/internal/generator/model_union_test.go create mode 100644 generators/go/sdk/changes/unreleased/suppress-duplicate-union-base-properties.yml diff --git a/generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts b/generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts index 90961ef371ab..029fd1b7bfaa 100644 --- a/generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts +++ b/generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts @@ -23,6 +23,7 @@ export const baseGoCustomConfigSchema = z.strictObject({ useReaderForBytesRequest: z.boolean().optional(), useDefaultRequestParameterValues: z.boolean().optional(), gettersPassByValue: z.boolean().optional(), + dedupeUnionBaseProperties: z.boolean().optional(), enableWireTests: z.boolean().optional(), exportAllRequestsAtRoot: z.boolean().optional(), customReadmeSections: z.array(CustomReadmeSectionSchema).optional(), diff --git a/generators/go/cmd/fern-go-model/main.go b/generators/go/cmd/fern-go-model/main.go index 6c1de92d2db5..c38a85d5c3dd 100644 --- a/generators/go/cmd/fern-go-model/main.go +++ b/generators/go/cmd/fern-go-model/main.go @@ -33,6 +33,7 @@ func run(config *cmd.Config, coordinator *coordinator.Client) ([]*generator.File config.InlineFileProperties, config.UseReaderForBytesRequest, config.GettersPassByValue, + config.DedupeUnionBaseProperties, config.ExportAllRequestsAtRoot, config.OmitEmptyRequestWrappers, config.OmitFernHeaders, diff --git a/generators/go/cmd/fern-go-sdk/main.go b/generators/go/cmd/fern-go-sdk/main.go index 7cea53b968de..b3e03e6c6ff5 100644 --- a/generators/go/cmd/fern-go-sdk/main.go +++ b/generators/go/cmd/fern-go-sdk/main.go @@ -33,6 +33,7 @@ func run(config *cmd.Config, coordinator *coordinator.Client) ([]*generator.File config.InlineFileProperties, config.UseReaderForBytesRequest, config.GettersPassByValue, + config.DedupeUnionBaseProperties, config.ExportAllRequestsAtRoot, config.OmitEmptyRequestWrappers, config.OmitFernHeaders, diff --git a/generators/go/internal/cmd/cmd.go b/generators/go/internal/cmd/cmd.go index 4db9f9db362a..bc66402ac3b4 100644 --- a/generators/go/internal/cmd/cmd.go +++ b/generators/go/internal/cmd/cmd.go @@ -63,6 +63,7 @@ type Config struct { InlineFileProperties bool UseReaderForBytesRequest bool GettersPassByValue bool + DedupeUnionBaseProperties bool ExportAllRequestsAtRoot bool OmitEmptyRequestWrappers bool OmitFernHeaders bool @@ -178,7 +179,7 @@ func run(fn GeneratorFunc) (retErr error) { if err := writeFiles(coordinator, config.Writer, config.Module, files); err != nil { return err } - + // Run the go-v2 SDK generator after files are written to disk, but only for client mode. // This ensures all files (including internal/caller.go and other templated files) // are available on disk before the go-v2 generator tries to read them. @@ -187,7 +188,7 @@ func run(fn GeneratorFunc) (retErr error) { return err } } - + return nil } @@ -236,6 +237,7 @@ func newConfig(configFilename string) (*Config, error) { EnableExplicitNull: *customConfig.EnableExplicitNull, UseReaderForBytesRequest: *customConfig.UseReaderForBytesRequest, GettersPassByValue: *customConfig.GettersPassByValue, + DedupeUnionBaseProperties: *customConfig.DedupeUnionBaseProperties, ExportAllRequestsAtRoot: *customConfig.ExportAllRequestsAtRoot, OmitEmptyRequestWrappers: *customConfig.OmitEmptyRequestWrappers, OmitFernHeaders: *customConfig.OmitFernHeaders, @@ -302,6 +304,7 @@ type customConfig struct { AlwaysSendRequiredProperties *bool `json:"alwaysSendRequiredProperties,omitempty"` UseReaderForBytesRequest *bool `json:"useReaderForBytesRequest,omitempty"` GettersPassByValue *bool `json:"gettersPassByValue,omitempty"` + DedupeUnionBaseProperties *bool `json:"dedupeUnionBaseProperties,omitempty"` ExportAllRequestsAtRoot *bool `json:"exportAllRequestsAtRoot,omitempty"` OmitEmptyRequestWrappers *bool `json:"omitEmptyRequestWrappers,omitempty"` OmitFernHeaders *bool `json:"omitFernHeaders,omitempty"` @@ -517,6 +520,9 @@ func applyCustomConfigDefaultsForV1(customConfig *customConfig) *customConfig { if customConfig.GettersPassByValue == nil { customConfig.GettersPassByValue = gospec.Ptr(false) } + if customConfig.DedupeUnionBaseProperties == nil { + customConfig.DedupeUnionBaseProperties = gospec.Ptr(false) + } if customConfig.ExportAllRequestsAtRoot == nil { customConfig.ExportAllRequestsAtRoot = gospec.Ptr(false) } @@ -583,6 +589,6 @@ func runGoV2Generator(coordinator *coordinator.Client) error { generatorexec.LogLevelDebug, "Successfully completed go-v2 SDK generator", ) - + return nil } diff --git a/generators/go/internal/generator/config.go b/generators/go/internal/generator/config.go index 5d6ac3d2bf14..26199915d332 100644 --- a/generators/go/internal/generator/config.go +++ b/generators/go/internal/generator/config.go @@ -27,6 +27,7 @@ type Config struct { InlineFileProperties bool UseReaderForBytesRequest bool GettersPassByValue bool + DedupeUnionBaseProperties bool ExportAllRequestsAtRoot bool OmitEmptyRequestWrappers bool OmitFernHeaders bool @@ -75,6 +76,7 @@ func NewConfig( inlineFileProperties bool, useReaderForBytesRequest bool, gettersPassByValue bool, + dedupeUnionBaseProperties bool, exportAllRequestsAtRoot bool, omitEmptyRequestWrappers bool, omitFernHeaders bool, @@ -108,6 +110,7 @@ func NewConfig( InlineFileProperties: inlineFileProperties, UseReaderForBytesRequest: useReaderForBytesRequest, GettersPassByValue: gettersPassByValue, + DedupeUnionBaseProperties: dedupeUnionBaseProperties, ExportAllRequestsAtRoot: exportAllRequestsAtRoot, OmitEmptyRequestWrappers: omitEmptyRequestWrappers, OmitFernHeaders: omitFernHeaders, diff --git a/generators/go/internal/generator/file_writer.go b/generators/go/internal/generator/file_writer.go index 0ea16a0bf245..61b801971902 100644 --- a/generators/go/internal/generator/file_writer.go +++ b/generators/go/internal/generator/file_writer.go @@ -79,6 +79,7 @@ type fileWriter struct { inlineFileProperties bool useReaderForBytesRequest bool gettersPassByValue bool + dedupeUnionBaseProperties bool exportAllRequestsAtRoot bool omitEmptyRequestWrappers bool omitFernHeaders bool @@ -144,6 +145,7 @@ func newFileWriter( inlineFileProperties bool, useReaderForBytesRequest bool, gettersPassByValue bool, + dedupeUnionBaseProperties bool, exportAllRequestsAtRoot bool, omitEmptyRequestWrappers bool, omitFernHeaders bool, @@ -193,6 +195,7 @@ func newFileWriter( inlineFileProperties: inlineFileProperties, useReaderForBytesRequest: useReaderForBytesRequest, gettersPassByValue: gettersPassByValue, + dedupeUnionBaseProperties: dedupeUnionBaseProperties, exportAllRequestsAtRoot: exportAllRequestsAtRoot, omitEmptyRequestWrappers: omitEmptyRequestWrappers, omitFernHeaders: omitFernHeaders, @@ -448,6 +451,7 @@ func (f *fileWriter) GenerateGetterSetterTestFile() (*File, error) { f.inlineFileProperties, f.useReaderForBytesRequest, f.gettersPassByValue, + f.dedupeUnionBaseProperties, f.exportAllRequestsAtRoot, f.omitEmptyRequestWrappers, f.omitFernHeaders, @@ -981,6 +985,7 @@ func (f *fileWriter) clone() *fileWriter { f.inlineFileProperties, f.useReaderForBytesRequest, f.gettersPassByValue, + f.dedupeUnionBaseProperties, f.exportAllRequestsAtRoot, f.omitEmptyRequestWrappers, f.omitFernHeaders, diff --git a/generators/go/internal/generator/generator.go b/generators/go/internal/generator/generator.go index 59778721f072..913cb7472889 100644 --- a/generators/go/internal/generator/generator.go +++ b/generators/go/internal/generator/generator.go @@ -197,6 +197,7 @@ func (g *Generator) generateModelTypes(ir *fernir.IntermediateRepresentation, mo g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -330,6 +331,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -358,6 +360,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -410,6 +413,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -450,6 +454,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -481,6 +486,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -518,6 +524,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -546,6 +553,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -577,6 +585,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -607,6 +616,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -662,6 +672,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) ( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -822,6 +833,7 @@ func (g *Generator) generateRootService( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -875,6 +887,7 @@ func (g *Generator) generateService( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -931,6 +944,7 @@ func (g *Generator) generateServiceWithoutEndpoints( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -982,6 +996,7 @@ func (g *Generator) generateRootServiceWithoutEndpoints( g.config.InlineFileProperties, g.config.UseReaderForBytesRequest, g.config.GettersPassByValue, + g.config.DedupeUnionBaseProperties, g.config.ExportAllRequestsAtRoot, g.config.OmitEmptyRequestWrappers, g.config.OmitFernHeaders, @@ -1357,6 +1372,7 @@ func newClientTestFile( false, false, false, + false, UnionVersionUnspecified, "", nil, diff --git a/generators/go/internal/generator/model.go b/generators/go/internal/generator/model.go index 5ce76efc8731..7fbf8c6e6d1d 100644 --- a/generators/go/internal/generator/model.go +++ b/generators/go/internal/generator/model.go @@ -36,6 +36,7 @@ func (f *fileWriter) WriteType( alwaysSendRequiredProperties: f.alwaysSendRequiredProperties, includeRawJSON: includeRawJSON, gettersPassByValue: f.gettersPassByValue, + dedupeUnionBaseProperties: f.dedupeUnionBaseProperties, } f.WriteDocs(typeDeclaration.Docs) return typeDeclaration.Shape.Accept(visitor) @@ -53,6 +54,7 @@ type typeVisitor struct { includeRawJSON bool alwaysSendRequiredProperties bool gettersPassByValue bool + dedupeUnionBaseProperties bool } // Compile-time assertion. @@ -451,6 +453,13 @@ func (t *typeVisitor) VisitUnion(union *ir.UnionTypeDeclaration) error { // be emitted twice, producing duplicate struct fields and getters that fail to // compile. Skip those base properties; the extended property already covers them. extendedPropertyNames := t.unionExtendedPropertyNames(union) + // Base properties that every variant already carries (e.g. properties lifted from a + // shared parent by `infer-discriminated-union-base-properties`) are suppressed as + // top-level struct fields. Emitting them would duplicate the data each variant + // already declares and, for samePropertiesAsObject variants, the top-level copy is + // silently dropped on marshal. They stay reachable through discriminant-switching + // getters (see writeUnionInheritedBasePropertyGetters below). + inheritedBasePropertyNames := t.unionInheritedBasePropertyNames(union) t.writer.P("type ", t.typeName, " struct {") t.writer.P(discriminantName, " string") var literals []*literal @@ -468,6 +477,9 @@ func (t *typeVisitor) VisitUnion(union *ir.UnionTypeDeclaration) error { if _, ok := extendedPropertyNames[goExportedFieldName(property.Name.Name.PascalCase.UnsafeName)]; ok { continue } + if _, ok := inheritedBasePropertyNames[goExportedFieldName(property.Name.Name.PascalCase.UnsafeName)]; ok { + continue + } if property.ValueType.Container != nil && property.ValueType.Container.Literal != nil { literals = append(literals, &literal{Name: property.Name, Value: property.ValueType.Container.Literal}) continue @@ -536,10 +548,13 @@ func (t *typeVisitor) VisitUnion(union *ir.UnionTypeDeclaration) error { receiver := typeNameToReceiver(t.typeName) // Implement the getter methods. - typeFields := t.getTypeFieldsForUnion(union) + typeFields := t.getTypeFieldsForUnion(union, inheritedBasePropertyNames) for _, typeField := range typeFields { t.writeGetterMethod(receiver, typeField) } + // Inherited base properties have no top-level field; expose them via getters that + // read from the active variant so there's a single source of truth. + t.writeUnionInheritedBasePropertyGetters(union, receiver, inheritedBasePropertyNames) for _, literal := range append(literals, unionLiterals...) { t.writer.P("func (", receiver, " *", t.typeName, ") ", literal.Name.Name.PascalCase.UnsafeName, "()", literalToGoType(literal.Value), "{") t.writer.P("if ", receiver, " == nil {") @@ -569,6 +584,9 @@ func (t *typeVisitor) VisitUnion(union *ir.UnionTypeDeclaration) error { if _, ok := extendedPropertyNames[goExportedFieldName(property.Name.Name.PascalCase.UnsafeName)]; ok { continue } + if _, ok := inheritedBasePropertyNames[goExportedFieldName(property.Name.Name.PascalCase.UnsafeName)]; ok { + continue + } t.writer.P(goExportedFieldName(property.Name.Name.PascalCase.UnsafeName), " ", typeReferenceToGoType(property.ValueType, t.writer.types, t.writer.scope, t.baseImportPath, t.importPath, false), jsonTagForType(property.Name.WireValue, property.ValueType, t.writer.types, t.alwaysSendRequiredProperties)) if property.ValueType.Container == nil || property.ValueType.Container.Literal == nil { propertyNames = append(propertyNames, goExportedFieldName(property.Name.Name.PascalCase.UnsafeName)) @@ -1632,8 +1650,160 @@ func (t *typeVisitor) unionExtendedPropertyNames(union *ir.UnionTypeDeclaration) return names } -// getTypeFieldsForUnion retrieves the type fields for the given union. -func (t *typeVisitor) getTypeFieldsForUnion(union *ir.UnionTypeDeclaration) []*typeField { +// objectExportedProperties returns the object's properties keyed by their exported Go +// field name, including those inherited via its `extends` chain. When a name is declared +// by both the object and a parent, the object's own property wins (Go field shadowing). +func objectExportedProperties( + object *ir.ObjectTypeDeclaration, + types map[common.TypeId]*ir.TypeDeclaration, +) map[string]*ir.ObjectProperty { + properties := make(map[string]*ir.ObjectProperty) + var collect func(obj *ir.ObjectTypeDeclaration) + collect = func(obj *ir.ObjectTypeDeclaration) { + if obj == nil { + return + } + for _, extend := range obj.Extends { + collect(resolveObjectTypeDeclaration(extend.TypeId, types)) + } + for _, property := range obj.Properties { + properties[goExportedFieldName(property.Name.Name.PascalCase.UnsafeName)] = property + } + } + collect(object) + return properties +} + +// unionInheritedBasePropertyNames returns the set of base-property field names that every +// variant of the union already carries AND can safely expose through a delegating getter. +// The OpenAPI parser lifts properties shared by all variants onto the union's +// BaseProperties so that generators without structural typing (Go, C#) can expose them. +// Re-emitting them as top-level struct fields, however, duplicates the data each variant +// already declares; and because samePropertiesAsObject variants are marshaled from the +// variant itself, the top-level copy is silently dropped on marshal. The caller +// suppresses these duplicate fields and instead exposes them through discriminant- +// switching getters that read from the active variant (see +// writeUnionInheritedBasePropertyGetters). +// +// A base property is only suppressed when every variant exposes a matching `Get()`, +// i.e. a non-literal property of the same name whose getter returns the same Go type as +// the base property's getter. Shapes that would make the delegating getter fail to +// compile are left alone (the base property keeps its own top-level field): +// - any variant that is not samePropertiesAsObject (carries no object properties); +// - a literal base property, or a variant whose same-named property is a literal (those +// use a `()` getter with no `Get` prefix, so a delegating `Get()` would +// reference a non-existent method); +// - a variant whose same-named property has a different type/optionality than the base +// property (the delegating call would return the wrong type). +// +// Gated behind the `dedupeUnionBaseProperties` config flag (default off) because removing +// the top-level fields is a breaking change to the generated surface; existing users keep +// the duplicated fields until they opt in. +func (t *typeVisitor) unionInheritedBasePropertyNames(union *ir.UnionTypeDeclaration) map[string]struct{} { + if !t.dedupeUnionBaseProperties { + return nil + } + if len(union.BaseProperties) == 0 || len(union.Types) == 0 { + return nil + } + variantProperties := make([]map[string]*ir.ObjectProperty, 0, len(union.Types)) + for _, unionType := range union.Types { + if unionType.Shape == nil || + unionType.Shape.PropertiesType != "samePropertiesAsObject" || + unionType.Shape.SamePropertiesAsObject == nil { + return nil + } + object := resolveObjectTypeDeclaration(unionType.Shape.SamePropertiesAsObject.TypeId, t.writer.types) + variantProperties = append(variantProperties, objectExportedProperties(object, t.writer.types)) + } + // Compare getter types against a throwaway scope so that resolving a variant + // property's type never registers an import on the real file scope: in the rare path + // where the types differ and the base property keeps its top-level field, the + // variant's (unused) type would otherwise leak in as an "imported and not used" error. + comparisonScope := gospec.NewScope() + inherited := make(map[string]struct{}) + for _, property := range union.BaseProperties { + if isLiteralType(property.ValueType, t.writer.types) { + continue + } + fieldName := goExportedFieldName(property.Name.Name.PascalCase.UnsafeName) + baseGetterType, _, _, _ := processTypeFieldForOptional(property.ValueType, t.writer.types, comparisonScope, t.baseImportPath, t.importPath, t.gettersPassByValue) + if t.everyVariantHasMatchingGetter(variantProperties, fieldName, baseGetterType, comparisonScope) { + inherited[fieldName] = struct{}{} + } + } + return inherited +} + +// everyVariantHasMatchingGetter reports whether every variant declares a non-literal +// property named fieldName whose getter returns baseGetterType — i.e. whether a +// delegating `Get() baseGetterType { ... return variant.Get() }` +// would compile for every variant. The scope is a throwaway used only to render +// comparable type strings; it must not be the real file scope. +func (t *typeVisitor) everyVariantHasMatchingGetter( + variantProperties []map[string]*ir.ObjectProperty, + fieldName string, + baseGetterType string, + comparisonScope *gospec.Scope, +) bool { + for _, properties := range variantProperties { + variantProperty, ok := properties[fieldName] + if !ok { + return false + } + if isLiteralType(variantProperty.ValueType, t.writer.types) { + return false + } + variantGetterType, _, _, _ := processTypeFieldForOptional(variantProperty.ValueType, t.writer.types, comparisonScope, t.baseImportPath, t.importPath, t.gettersPassByValue) + if variantGetterType != baseGetterType { + return false + } + } + return true +} + +// writeUnionInheritedBasePropertyGetters emits getters for base properties that every +// variant already carries. Instead of reading a (suppressed) top-level field, each +// getter switches on the discriminant and returns the value from the active variant, +// keeping a single source of truth while still letting callers reach the shared field +// without a type switch of their own. +func (t *typeVisitor) writeUnionInheritedBasePropertyGetters( + union *ir.UnionTypeDeclaration, + receiver string, + inheritedBasePropertyNames map[string]struct{}, +) { + if len(inheritedBasePropertyNames) == 0 { + return + } + discriminantName := t.unionDiscriminantFieldName(union) + for _, property := range union.BaseProperties { + fieldName := goExportedFieldName(property.Name.Name.PascalCase.UnsafeName) + if _, ok := inheritedBasePropertyNames[fieldName]; !ok { + continue + } + goType, zeroValue, _, _ := processTypeFieldForOptional(property.ValueType, t.writer.types, t.writer.scope, t.baseImportPath, t.importPath, t.gettersPassByValue) + t.writer.P("func (", receiver, " *", t.typeName, ") Get", fieldName, "()", goType, " {") + t.writer.P("if ", receiver, " == nil {") + t.writer.P("return ", zeroValue) + t.writer.P("}") + t.writer.P("switch ", receiver, ".", discriminantName, " {") + for _, unionType := range union.Types { + variantFieldName := goExportedFieldName(unionType.DiscriminantValue.Name.PascalCase.UnsafeName) + t.writer.P("case \"", unionType.DiscriminantValue.WireValue, "\":") + t.writer.P("return ", receiver, ".", variantFieldName, ".Get", fieldName, "()") + } + t.writer.P("}") + t.writer.P("return ", zeroValue) + t.writer.P("}") + t.writer.P() + } +} + +// getTypeFieldsForUnion retrieves the type fields for the given union. The caller passes +// the already-computed inheritedBasePropertyNames (see VisitUnion) so the set isn't +// recomputed; inherited base properties are emitted as discriminant-switching getters +// rather than stored fields, so they are excluded here. +func (t *typeVisitor) getTypeFieldsForUnion(union *ir.UnionTypeDeclaration, inheritedBasePropertyNames map[string]struct{}) []*typeField { var fields []*typeField fields = append( fields, @@ -1657,6 +1827,10 @@ func (t *typeVisitor) getTypeFieldsForUnion(union *ir.UnionTypeDeclaration) []*t if _, ok := extendedPropertyNames[goExportedFieldName(property.Name.Name.PascalCase.UnsafeName)]; ok { continue } + if _, ok := inheritedBasePropertyNames[goExportedFieldName(property.Name.Name.PascalCase.UnsafeName)]; ok { + // Emitted as a discriminant-switching getter instead of a stored field. + continue + } if isLiteralType(property.ValueType, t.writer.types) { continue } diff --git a/generators/go/internal/generator/model_union_test.go b/generators/go/internal/generator/model_union_test.go new file mode 100644 index 000000000000..c495c3e6fa1a --- /dev/null +++ b/generators/go/internal/generator/model_union_test.go @@ -0,0 +1,206 @@ +package generator + +import ( + "testing" + + "github.com/fern-api/fern-go/internal/fern/ir" + "github.com/fern-api/fern-go/internal/fern/ir/common" +) + +func nameAndWireValue(pascal string) *common.NameAndWireValue { + return &common.NameAndWireValue{ + WireValue: pascal, + Name: &common.Name{ + OriginalName: pascal, + PascalCase: &common.SafeAndUnsafeString{UnsafeName: pascal, SafeName: pascal}, + }, + } +} + +func primitiveProperty(pascal string, primitive common.PrimitiveTypeV1) *ir.ObjectProperty { + return &ir.ObjectProperty{ + Name: nameAndWireValue(pascal), + ValueType: &ir.TypeReference{Primitive: &ir.PrimitiveType{V1: primitive}}, + } +} + +func objectProperty(pascal string) *ir.ObjectProperty { + return primitiveProperty(pascal, common.PrimitiveTypeV1String) +} + +func literalObjectProperty(pascal string) *ir.ObjectProperty { + return &ir.ObjectProperty{ + Name: nameAndWireValue(pascal), + ValueType: &ir.TypeReference{Container: &ir.ContainerType{Literal: &ir.Literal{Type: "string", String: pascal}}}, + } +} + +func objectType(properties ...*ir.ObjectProperty) *ir.TypeDeclaration { + return &ir.TypeDeclaration{Shape: &ir.Type{Object: &ir.ObjectTypeDeclaration{Properties: properties}}} +} + +func objectTypeDeclaration(propertyNames ...string) *ir.TypeDeclaration { + var properties []*ir.ObjectProperty + for _, name := range propertyNames { + properties = append(properties, objectProperty(name)) + } + return objectType(properties...) +} + +func samePropertiesAsObjectVariant(typeID common.TypeId) *ir.SingleUnionType { + return &ir.SingleUnionType{ + DiscriminantValue: nameAndWireValue(typeID), + Shape: &ir.SingleUnionTypeProperties{ + PropertiesType: "samePropertiesAsObject", + SamePropertiesAsObject: &ir.DeclaredTypeName{TypeId: typeID}, + }, + } +} + +func TestUnionInheritedBasePropertyNames(t *testing.T) { + tv := &typeVisitor{ + dedupeUnionBaseProperties: true, + writer: &fileWriter{ + types: map[common.TypeId]*ir.TypeDeclaration{ + "Foo": objectTypeDeclaration("Name", "Id"), + "Bar": objectTypeDeclaration("Name"), + }, + }, + } + + t.Run("suppresses only base properties every variant already carries", func(t *testing.T) { + union := &ir.UnionTypeDeclaration{ + BaseProperties: []*ir.ObjectProperty{objectProperty("Name"), objectProperty("Id")}, + Types: []*ir.SingleUnionType{ + samePropertiesAsObjectVariant("Foo"), + samePropertiesAsObjectVariant("Bar"), + }, + } + got := tv.unionInheritedBasePropertyNames(union) + if _, ok := got["Name"]; !ok { + t.Errorf("expected Name to be suppressed: it is carried by both Foo and Bar") + } + // Id is only declared by Foo, so it is a genuine union-level base property and + // must keep its top-level field. + if _, ok := got["Id"]; ok { + t.Errorf("did not expect Id to be suppressed: only Foo carries it") + } + }) + + t.Run("suppresses nothing when a variant is not an object", func(t *testing.T) { + union := &ir.UnionTypeDeclaration{ + BaseProperties: []*ir.ObjectProperty{objectProperty("Name")}, + Types: []*ir.SingleUnionType{ + samePropertiesAsObjectVariant("Foo"), + {Shape: &ir.SingleUnionTypeProperties{PropertiesType: "singleProperty"}}, + }, + } + if got := tv.unionInheritedBasePropertyNames(union); len(got) != 0 { + t.Errorf("expected no suppression when a variant carries no object properties, got %v", got) + } + }) + + t.Run("no base properties means nothing to suppress", func(t *testing.T) { + union := &ir.UnionTypeDeclaration{ + Types: []*ir.SingleUnionType{samePropertiesAsObjectVariant("Foo")}, + } + if got := tv.unionInheritedBasePropertyNames(union); len(got) != 0 { + t.Errorf("expected empty result, got %v", got) + } + }) + + t.Run("never suppresses literal base properties even when carried by every variant", func(t *testing.T) { + // A literal property keeps its own `()` getter (no `Get` prefix) on both + // the union and each variant. Suppressing it would emit a delegating + // `Get()` that calls the variant's non-existent `Get()` and fail to + // compile, so literals must stay on the normal path. + litTv := &typeVisitor{ + dedupeUnionBaseProperties: true, + writer: &fileWriter{ + types: map[common.TypeId]*ir.TypeDeclaration{ + "A": objectTypeDeclaration("Name", "Kind"), + "B": objectTypeDeclaration("Name", "Kind"), + }, + }, + } + union := &ir.UnionTypeDeclaration{ + BaseProperties: []*ir.ObjectProperty{objectProperty("Name"), literalObjectProperty("Kind")}, + Types: []*ir.SingleUnionType{ + samePropertiesAsObjectVariant("A"), + samePropertiesAsObjectVariant("B"), + }, + } + got := litTv.unionInheritedBasePropertyNames(union) + if _, ok := got["Name"]; !ok { + t.Errorf("expected non-literal common property Name to be suppressed") + } + if _, ok := got["Kind"]; ok { + t.Errorf("did not expect literal property Kind to be suppressed") + } + }) + + t.Run("does not suppress when a variant's same-named property has a different type", func(t *testing.T) { + // The delegating getter would be `GetName() string { ... return c.A.GetName() }`, + // but the variant's GetName() returns int — a type mismatch that would not compile. + mismatchTv := &typeVisitor{ + dedupeUnionBaseProperties: true, + writer: &fileWriter{ + types: map[common.TypeId]*ir.TypeDeclaration{ + "A": objectType(primitiveProperty("Name", common.PrimitiveTypeV1Integer)), + "B": objectType(primitiveProperty("Name", common.PrimitiveTypeV1Integer)), + }, + }, + } + union := &ir.UnionTypeDeclaration{ + BaseProperties: []*ir.ObjectProperty{primitiveProperty("Name", common.PrimitiveTypeV1String)}, + Types: []*ir.SingleUnionType{ + samePropertiesAsObjectVariant("A"), + samePropertiesAsObjectVariant("B"), + }, + } + if got := mismatchTv.unionInheritedBasePropertyNames(union); len(got) != 0 { + t.Errorf("expected no suppression when base (string) and variant (int) types differ, got %v", got) + } + }) + + t.Run("does not suppress when a variant's same-named property is a literal", func(t *testing.T) { + // Base `Kind` is non-literal, but each variant's `Kind` is a literal whose getter is + // `Kind()` (no `Get` prefix); a delegating `Get()` would not compile. + literalVariantTv := &typeVisitor{ + dedupeUnionBaseProperties: true, + writer: &fileWriter{ + types: map[common.TypeId]*ir.TypeDeclaration{ + "A": objectType(literalObjectProperty("Kind")), + "B": objectType(literalObjectProperty("Kind")), + }, + }, + } + union := &ir.UnionTypeDeclaration{ + BaseProperties: []*ir.ObjectProperty{primitiveProperty("Kind", common.PrimitiveTypeV1String)}, + Types: []*ir.SingleUnionType{ + samePropertiesAsObjectVariant("A"), + samePropertiesAsObjectVariant("B"), + }, + } + if got := literalVariantTv.unionInheritedBasePropertyNames(union); len(got) != 0 { + t.Errorf("expected no suppression when a variant's same-named property is a literal, got %v", got) + } + }) + + t.Run("suppresses nothing when the dedupeUnionBaseProperties flag is off", func(t *testing.T) { + off := &typeVisitor{ + dedupeUnionBaseProperties: false, + writer: tv.writer, + } + union := &ir.UnionTypeDeclaration{ + BaseProperties: []*ir.ObjectProperty{objectProperty("Name")}, + Types: []*ir.SingleUnionType{ + samePropertiesAsObjectVariant("Foo"), + samePropertiesAsObjectVariant("Bar"), + }, + } + if got := off.unionInheritedBasePropertyNames(union); len(got) != 0 { + t.Errorf("expected no suppression when flag is off, got %v", got) + } + }) +} diff --git a/generators/go/sdk/changes/unreleased/suppress-duplicate-union-base-properties.yml b/generators/go/sdk/changes/unreleased/suppress-duplicate-union-base-properties.yml new file mode 100644 index 000000000000..4e6e02b4e90d --- /dev/null +++ b/generators/go/sdk/changes/unreleased/suppress-duplicate-union-base-properties.yml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Add a `dedupeUnionBaseProperties` config option (default `false`). When enabled, a + discriminated union no longer emits a duplicate top-level field for base properties + that every variant already carries (e.g. properties lifted from a shared parent by + `infer-discriminated-union-base-properties`). Without it, such a property appears both + on the union and inside each variant struct, and the top-level copy is silently + dropped when marshaling `samePropertiesAsObject` variants. With the flag on, the + shared property is exposed through a getter that switches on the discriminant and reads + from the active variant — a single source of truth that still allows top-level access + without a type switch. The flag is opt-in to preserve the existing generated surface; + the default will flip in a future major version with a migration. + type: feat From e3f641363cacc24d83549f11336facf7b793b87e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 05:10:13 +0000 Subject: [PATCH 2/2] chore(go): release 1.46.0 --- .../suppress-duplicate-union-base-properties.yml | 0 generators/go/sdk/versions.yml | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) rename generators/go/sdk/changes/{unreleased => 1.46.0}/suppress-duplicate-union-base-properties.yml (100%) diff --git a/generators/go/sdk/changes/unreleased/suppress-duplicate-union-base-properties.yml b/generators/go/sdk/changes/1.46.0/suppress-duplicate-union-base-properties.yml similarity index 100% rename from generators/go/sdk/changes/unreleased/suppress-duplicate-union-base-properties.yml rename to generators/go/sdk/changes/1.46.0/suppress-duplicate-union-base-properties.yml diff --git a/generators/go/sdk/versions.yml b/generators/go/sdk/versions.yml index 821ffe1fe700..d3cd5a04a016 100644 --- a/generators/go/sdk/versions.yml +++ b/generators/go/sdk/versions.yml @@ -1,4 +1,20 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 1.46.0 + changelogEntry: + - summary: | + Add a `dedupeUnionBaseProperties` config option (default `false`). When enabled, a + discriminated union no longer emits a duplicate top-level field for base properties + that every variant already carries (e.g. properties lifted from a shared parent by + `infer-discriminated-union-base-properties`). Without it, such a property appears both + on the union and inside each variant struct, and the top-level copy is silently + dropped when marshaling `samePropertiesAsObject` variants. With the flag on, the + shared property is exposed through a getter that switches on the discriminant and reads + from the active variant — a single source of truth that still allows top-level access + without a type switch. The flag is opt-in to preserve the existing generated surface; + the default will flip in a future major version with a migration. + type: feat + createdAt: "2026-06-18" + irVersion: 66 - version: 1.45.5 changelogEntry: - summary: |