From 4832be01b6f6e10fdd373fbbfb6b879148a58da7 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 11 Jun 2026 00:17:46 +0200 Subject: [PATCH 1/2] fix(python): avoid union case field name collision with Union.name A named union case field called `name` (e.g. `Case of name: string * optional: string`) generated a Python dataclass field `name: str`. The `Union` base class defines a `name` property, which `@dataclass` treats as the field's default value, so any following field without a default raised "non-default argument follows default argument" at runtime. Use `toRecordFieldSnakeCase` for union case fields (same convention records already use), so `name` becomes `name_` and no longer collides. Union construction is positional and field access is by index, so the dataclass field name is purely internal. Fixes #4645 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Python/Fable2Python.Transforms.fs | 10 +++++++--- tests/Python/TestUnionType.fs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index d400f59cb8..1ffbc616c2 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -4097,9 +4097,13 @@ let transformUnion (com: IPythonCompiler) ctx (ent: Fable.Entity) (entName: stri let fieldAnnotations = uci.UnionCaseFields |> List.map (fun field -> - // Convert to snake_case and clean to remove invalid characters like apostrophes - // Handles: "Item" -> "item", "Item1" -> "item1", "MyField" -> "my_field" - let fieldName = field.Name |> Naming.toSnakeCase |> Helpers.clean + // Convert to snake_case and clean to remove invalid characters like apostrophes. + // Use the record field naming convention (appends '_' to camelCase names) so a + // field like "name" becomes "name_" and does not collide with the inherited + // `Union.name` property, which @dataclass would otherwise treat as a default value + // (causing "non-default argument follows default argument"). See #4645. + // Handles: "Item" -> "item", "Item1" -> "item1", "MyField" -> "my_field", "name" -> "name_" + let fieldName = field.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean // Uncurry lambda types for field annotations since union case fields // store uncurried functions at runtime (same as record fields) let fieldType = diff --git a/tests/Python/TestUnionType.fs b/tests/Python/TestUnionType.fs index c8aa09712a..ec6ab1c3f0 100644 --- a/tests/Python/TestUnionType.fs +++ b/tests/Python/TestUnionType.fs @@ -211,3 +211,17 @@ type S = S of string let ``test sprintf formats strings cases correctly`` () = let s = sprintf "%A" (S "1") s |> equal "S \"1\"" + +// See https://github.com/fable-compiler/Fable/issues/4645 +// A named union case field called `name` collided with the inherited `Union.name` +// property, which Python's @dataclass treated as a default value. +type NamedFieldUnion = + | NamedCase of name: string * optional: string + +[] +let ``test Named union case fields work when a field is called name`` () = + let value = NamedCase("v1", "v2") + match value with + | NamedCase(name, optional) -> + name |> equal "v1" + optional |> equal "v2" From a539c2d5582d98426c49274cd8b20e75a766abed Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 11 Jun 2026 00:31:58 +0200 Subject: [PATCH 2/2] refactor(python): rename toRecordFieldSnakeCase to toFieldSnakeCase The helper is now used for union case fields too, not just records, so the "Record" qualifier is misleading. Rename to toFieldSnakeCase and tidy the related comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Python/Fable2Python.Reflection.fs | 2 +- .../Python/Fable2Python.Transforms.fs | 23 ++++++++----------- .../Python/Fable2Python.Util.fs | 6 ++--- src/Fable.Transforms/Python/Prelude.fs | 5 ++-- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs index 205eb35f25..e62c6343ec 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs @@ -40,7 +40,7 @@ let private transformRecordReflectionInfo com ctx r (ent: Fable.Entity) generics let name = if Util.shouldUseRecordFieldNaming ent then - fi.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean + fi.Name |> Naming.toFieldSnakeCase |> Helpers.clean else fi.Name |> Naming.toSnakeCase |> Helpers.clean diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 1ffbc616c2..53d121e609 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -3223,7 +3223,7 @@ let getUnionFieldsAsIdents (_com: IPythonCompiler) _ctx (_ent: Fable.Entity) = let getEntityFieldsAsIdents (com: IPythonCompiler) (ent: Fable.Entity) = let entityNamingConvention = if shouldUseRecordFieldNaming ent then - Naming.toRecordFieldSnakeCase + Naming.toFieldSnakeCase else Naming.toPythonNaming @@ -3248,7 +3248,7 @@ let getEntityFieldsAsProps (com: IPythonCompiler) ctx (ent: Fable.Entity) = else let namingConvention = if shouldUseRecordFieldNaming ent then - Naming.toRecordFieldSnakeCase + Naming.toFieldSnakeCase else Naming.toPythonNaming @@ -3290,7 +3290,7 @@ let declareDataClassType consArgs.Args.[i].Arg else // Fallback to field name if consArgs doesn't have enough args - com.GetIdentifier(ctx, field.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean) + com.GetIdentifier(ctx, field.Name |> Naming.toFieldSnakeCase |> Helpers.clean) // Uncurry lambda types for field annotations since fields store uncurried functions let fieldType = @@ -3943,7 +3943,7 @@ let transformAttachedProperty // Apply the same naming convention as record fields for record types let propertyName = //if shouldUseRecordFieldNaming ent then - // memb.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean + // memb.Name |> Naming.toFieldSnakeCase |> Helpers.clean //else memb.Name |> Naming.toPropertyNaming @@ -4097,13 +4097,10 @@ let transformUnion (com: IPythonCompiler) ctx (ent: Fable.Entity) (entName: stri let fieldAnnotations = uci.UnionCaseFields |> List.map (fun field -> - // Convert to snake_case and clean to remove invalid characters like apostrophes. - // Use the record field naming convention (appends '_' to camelCase names) so a - // field like "name" becomes "name_" and does not collide with the inherited - // `Union.name` property, which @dataclass would otherwise treat as a default value - // (causing "non-default argument follows default argument"). See #4645. - // Handles: "Item" -> "item", "Item1" -> "item1", "MyField" -> "my_field", "name" -> "name_" - let fieldName = field.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean + // toFieldSnakeCase appends '_' to camelCase names ("name" -> "name_") so a + // field can't collide with the inherited `Union.name` property, which @dataclass + // would treat as a default value ("non-default argument follows default"). See #4645. + let fieldName = field.Name |> Naming.toFieldSnakeCase |> Helpers.clean // Uncurry lambda types for field annotations since union case fields // store uncurried functions at runtime (same as record fields) let fieldType = @@ -4242,7 +4239,7 @@ let transformClassWithCompilerGeneratedConstructor |> List.collecti (fun i field -> let fieldName = if shouldUseRecordFieldNaming ent then - field.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean + field.Name |> Naming.toFieldSnakeCase |> Helpers.clean else match Util.getFieldNamingKind com field.FieldType field.Name with | InstancePropertyBacking -> field.Name |> Naming.toPropertyBackingFieldNaming @@ -4684,7 +4681,7 @@ let transformStaticProperty // printfn "transformStaticProperty: %A" propName let propertyName = if shouldUseRecordFieldNaming ent then - propName |> Naming.toRecordFieldSnakeCase |> Helpers.clean + propName |> Naming.toFieldSnakeCase |> Helpers.clean else propName |> Naming.toPropertyNaming diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index 676f000370..97cc829ab2 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -463,13 +463,13 @@ module Util = name - /// Determines if we should use the special record field naming convention (toRecordFieldSnakeCase) + /// Determines if we should use the special field naming convention (toFieldSnakeCase) /// for the given entity. Returns true for user-defined F# records, false for built-in types. let shouldUseRecordFieldNaming (ent: Fable.Entity) = ent.IsFSharpRecord && not (ent.FullName.StartsWith("Microsoft.FSharp.Core", StringComparison.Ordinal)) - /// Determines if we should use the special record field naming convention (toRecordFieldSnakeCase) + /// Determines if we should use the special field naming convention (toFieldSnakeCase) /// for the given entity reference. Returns true for user-defined F# records, false for built-in types. let shouldUseRecordFieldNamingForRef (entityRef: Fable.EntityRef) (ent: Fable.Entity) = ent.IsFSharpRecord @@ -510,7 +510,7 @@ module Util = | Fable.DeclaredType(entityRef, _) -> match com.TryGetEntity entityRef with | Some ent when shouldUseRecordFieldNamingForRef entityRef ent && isRecordField ent fieldName -> - fieldName |> Naming.toRecordFieldSnakeCase |> Helpers.clean + fieldName |> Naming.toFieldSnakeCase |> Helpers.clean | _ -> fieldName |> Naming.toPythonNaming // Fallback to Python naming for other types | _ -> fieldName |> Naming.toPropertyNaming diff --git a/src/Fable.Transforms/Python/Prelude.fs b/src/Fable.Transforms/Python/Prelude.fs index 1fc2332a36..d14f71f8a6 100644 --- a/src/Fable.Transforms/Python/Prelude.fs +++ b/src/Fable.Transforms/Python/Prelude.fs @@ -33,10 +33,11 @@ module Naming = let toSnakeCase (name: string) = Naming.applyCaseRule CaseRules.SnakeCase name - /// Convert F# record field name to snake_case with special handling for camelCase/PascalCase conflicts. + /// Convert an F# field name (record or union case field) to snake_case with special handling + /// for camelCase/PascalCase conflicts. /// - If the name is PascalCase, convert to snake_case without suffix. /// - If the name is camelCase, convert to snake_case and add '_' suffix to avoid conflict with PascalCase. - let toRecordFieldSnakeCase (name: string) = + let toFieldSnakeCase (name: string) = let snakeCase = Naming.applyCaseRule CaseRules.SnakeCase name if name.Length > 0 && Char.IsLower(name.[0]) then