diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 3f7378ebed..5983790397 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +* [JS/TS] Fix `Unchecked.defaultof<'T>` for struct types with fields returning `undefined` instead of zero-initialized values (by @MangelMaxime) +* [Python] Fix `Unchecked.defaultof<'T>` for struct types with fields returning incorrect values instead of zero-initialized instances (by @MangelMaxime) +* [Python] Fix `Unchecked.defaultof` returning `""` (empty string) instead of `"\u0000"` (null character) (by @MangelMaxime) +* [Python] Improve `Unchecked.defaultof<_>` for declared entities (by @MangelMaxime) + ## 5.0.0-rc.5 - 2026-03-20 ### Added diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 4c334705f3..33f38a66db 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +* [JS/TS] Fix `Unchecked.defaultof<'T>` for struct types with fields returning `undefined` instead of zero-initialized values (by @MangelMaxime) +* [Python] Fix `Unchecked.defaultof<'T>` for struct types with fields returning incorrect values instead of zero-initialized instances (by @MangelMaxime) +* [Python] Fix `Unchecked.defaultof` returning `""` (empty string) instead of `"\u0000"` (null character) (by @MangelMaxime) +* [Python] Improve `Unchecked.defaultof<_>` for declared entities (by @MangelMaxime) + ## 5.0.0-rc.11 - 2026-03-20 ### Added diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index ce58803536..e95fdbcf94 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -805,9 +805,15 @@ module Util = | Float32 -> makeFloat com ctx None t "float32" 0.0 |> fst | Float64 -> makeFloat com ctx None t "float64" 0.0 |> fst | Decimal -> makeFloat com ctx None t "float64" 0.0 |> fst - | Fable.String - | Fable.Char -> Expression.stringConstant "" - | Fable.DeclaredType(ent, _) -> Expression.none + | Fable.Char -> Expression.stringConstant "\u0000" + | Fable.String -> Expression.stringConstant "" + | Fable.DeclaredType(entRef, _) -> + match com.TryGetEntity(entRef) with + | Some ent when ent.IsValueType -> + match tryPyConstructor com ctx ent with + | Some(ctorExpr, _stmts) -> Expression.call (ctorExpr, []) + | None -> Expression.none + | _ -> Expression.none | Fable.GenericParam _ -> libValue com ctx "util" "UNIT" | _ -> Expression.none diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 4d9e4867fc..b022712352 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -727,13 +727,82 @@ let makeHashSet (com: ICompiler) ctx r t sourceSeq = makeEqualityComparer com ctx key |> makeHashSetWithComparer com r t sourceSeq | _ -> Helper.GlobalCall("set", t, [ sourceSeq ], isConstructor = true, ?loc = r) -let rec getZero (com: ICompiler) ctx (t: Type) = +let tryEntityIdent (com: Compiler) entFullName = + match entFullName with + | BuiltinDefinition BclDateOnly + | BuiltinDefinition BclDateTime + | BuiltinDefinition BclDateTimeOffset -> makeIdentExpr "Date" |> Some + | BuiltinDefinition BclTimer -> makeImportLib com Any "default" "Timer" |> Some + | BuiltinDefinition(FSharpReference _) -> makeImportLib com Any "FSharpRef" "Types" |> Some + | BuiltinDefinition(FSharpResult _) -> + // Import the underscore-prefixed base class (has cases() method), not the type alias + makeImportLib com Any "_FSharpResult_2" "Result" |> Some + | BuiltinDefinition(FSharpChoice genArgs) -> + // Import the underscore-prefixed base class (has cases() method), not the type alias + let membName = $"_FSharpChoice_%d{List.length genArgs}" + makeImportLib com Any membName "Choice" |> Some + // | BuiltinDefinition BclGuid -> jsTypeof "string" expr + // | BuiltinDefinition BclTimeSpan -> jsTypeof "number" expr + // | BuiltinDefinition BclHashSet _ -> fail "MutableSet" // TODO: + // | BuiltinDefinition BclDictionary _ -> fail "MutableMap" // TODO: + // | BuiltinDefinition BclKeyValuePair _ -> fail "KeyValuePair" // TODO: + // | BuiltinDefinition FSharpSet _ -> fail "Set" // TODO: + // | BuiltinDefinition FSharpMap _ -> fail "Map" // TODO: + | Types.matchFail -> makeImportLib com Any "MatchFailureException" "Types" |> Some + | Types.exception_ -> makeIdentExpr "Exception" |> Some + // | "System.Collections.Generic.KeyNotFoundException" -> + // makeImportLib com Any "KeyNotFoundException" "System.Collections.Generic" |> Some + | BuiltinSystemException entName -> makeImportLib com Any entName "System" |> Some + // | Naming.EndsWith "Exception" _ -> makeIdentExpr "Exception" |> Some + | _ -> None + +let tryConstructor com (ent: Entity) = + if FSharp2Fable.Util.isReplacementCandidate ent.Ref then + tryEntityIdent com (ent.FullName |> Naming.toPythonNaming) + else + match FSharp2Fable.Util.tryEntityIdentMaybeGlobalOrImported com ent with + | Some(IdentExpr ident) when ent.IsFSharpUnion -> + // For F# union types, the base class is prefixed with underscore (_UnionName) + // This is needed for both reflection (base class has cases() method) and + // type annotations (self inside base class methods) + Some(IdentExpr { ident with Name = "_" + ident.Name }) + | other -> other + +let constructor com ent = + match tryConstructor com ent with + | Some e -> e + | None -> $"Cannot find %s{ent.FullName} constructor" |> addErrorAndReturnNull com [] None + +/// For user-defined value types (structs), returns a constructor call initialized with +/// a zero value for each instance field. Only instance fields are constructor parameters; +/// static fields (from `static let` bindings) are excluded to avoid deep recursion through +/// static field type graphs. Returns None if no constructor is available or the type is +/// not a value type. +let rec private tryZeroValueTypeConstructor (com: ICompiler) (ctx: Context) (t: Type) (ent: Entity) = + if ent.IsValueType then + tryConstructor com ent + |> Option.map (fun e -> + let args = + ent.FSharpFields + |> List.choose (fun f -> + if f.IsStatic then + None + else + getZero com ctx f.FieldType |> Some + ) + + Helper.ConstructorCall(e, t, args) + ) + else + None + +and private getZero (com: ICompiler) ctx (t: Type) = match t with | Boolean -> makeBoolConst false | Number(BigInt, _) as t -> Helper.LibCall(com, "big_int", "fromInt32", t, [ makeIntConst 0 ]) | Number(Decimal, _) as t -> makeIntConst 0 |> makeDecimalFromExpr com None t | Number(kind, uom) -> NumberConstant(NumberValue.GetZero kind, uom) |> makeValue None - | Char + | Char -> makeCharConst '\u0000' | String -> makeStrConst "" // TODO: Use null for string? | Builtin BclTimeSpan -> Helper.LibCall(com, "time_span", "create", t, [ makeIntConst 0 ]) | Builtin BclDateTime as t -> Helper.LibCall(com, "date", "minValue", t, []) @@ -741,6 +810,10 @@ let rec getZero (com: ICompiler) ctx (t: Type) = | Builtin(FSharpSet genArg) as t -> makeSet com ctx None t "Empty" [] genArg | Builtin(BclKeyValuePair(k, v)) -> makeTuple None true [ getZero com ctx k; getZero com ctx v ] | ListSingleton(CustomOp com ctx None t "get_Zero" [] e) -> e + | DeclaredType(entRef, _) -> + com.GetEntity(entRef) + |> tryZeroValueTypeConstructor com ctx t + |> Option.defaultValue (Value(Null Any, None)) // null | _ -> Value(Null Any, None) // null let getOne (com: ICompiler) ctx (t: Type) = @@ -810,52 +883,6 @@ let injectArg (com: ICompiler) (ctx: Context) r moduleName methName (genArgs: Ty | None -> args | Some injectInfo -> injectArgInner args injectInfo -let tryEntityIdent (com: Compiler) entFullName = - match entFullName with - | BuiltinDefinition BclDateOnly - | BuiltinDefinition BclDateTime - | BuiltinDefinition BclDateTimeOffset -> makeIdentExpr "Date" |> Some - | BuiltinDefinition BclTimer -> makeImportLib com Any "default" "Timer" |> Some - | BuiltinDefinition(FSharpReference _) -> makeImportLib com Any "FSharpRef" "Types" |> Some - | BuiltinDefinition(FSharpResult _) -> - // Import the underscore-prefixed base class (has cases() method), not the type alias - makeImportLib com Any "_FSharpResult_2" "Result" |> Some - | BuiltinDefinition(FSharpChoice genArgs) -> - // Import the underscore-prefixed base class (has cases() method), not the type alias - let membName = $"_FSharpChoice_%d{List.length genArgs}" - makeImportLib com Any membName "Choice" |> Some - // | BuiltinDefinition BclGuid -> jsTypeof "string" expr - // | BuiltinDefinition BclTimeSpan -> jsTypeof "number" expr - // | BuiltinDefinition BclHashSet _ -> fail "MutableSet" // TODO: - // | BuiltinDefinition BclDictionary _ -> fail "MutableMap" // TODO: - // | BuiltinDefinition BclKeyValuePair _ -> fail "KeyValuePair" // TODO: - // | BuiltinDefinition FSharpSet _ -> fail "Set" // TODO: - // | BuiltinDefinition FSharpMap _ -> fail "Map" // TODO: - | Types.matchFail -> makeImportLib com Any "MatchFailureException" "Types" |> Some - | Types.exception_ -> makeIdentExpr "Exception" |> Some - // | "System.Collections.Generic.KeyNotFoundException" -> - // makeImportLib com Any "KeyNotFoundException" "System.Collections.Generic" |> Some - | BuiltinSystemException entName -> makeImportLib com Any entName "System" |> Some - // | Naming.EndsWith "Exception" _ -> makeIdentExpr "Exception" |> Some - | _ -> None - -let tryConstructor com (ent: Entity) = - if FSharp2Fable.Util.isReplacementCandidate ent.Ref then - tryEntityIdent com (ent.FullName |> Naming.toPythonNaming) - else - match FSharp2Fable.Util.tryEntityIdentMaybeGlobalOrImported com ent with - | Some(IdentExpr ident) when ent.IsFSharpUnion -> - // For F# union types, the base class is prefixed with underscore (_UnionName) - // This is needed for both reflection (base class has cases() method) and - // type annotations (self inside base class methods) - Some(IdentExpr { ident with Name = "_" + ident.Name }) - | other -> other - -let constructor com ent = - match tryConstructor com ent with - | Some e -> e - | None -> $"Cannot find %s{ent.FullName} constructor" |> addErrorAndReturnNull com [] None - let tryOp com r t op args = Helper.LibCall(com, "option", "tryOp", t, op :: args, ?loc = r) @@ -871,6 +898,7 @@ let rec defaultof com ctx r t = | Nullable _ -> Value(Null t, r) | Tuple(args, true) -> NewTuple(args |> List.map (defaultof com ctx r), true) |> makeValue None | Boolean + | Char | Number _ | Builtin BclTimeSpan | Builtin BclDateTime @@ -879,11 +907,7 @@ let rec defaultof com ctx r t = | DeclaredType(ent, _) -> let ent = com.GetEntity(ent) // TODO: For BCL types we cannot access the constructor, raise error or warning? - if ent.IsValueType then - tryConstructor com ent - else - None - |> Option.map (fun e -> Helper.ConstructorCall(e, t, [])) + tryZeroValueTypeConstructor com ctx t ent |> Option.defaultWith (fun () -> Null t |> makeValue None) // TODO: Fail (or raise warning) if this is an unresolved generic parameter? | _ -> Null t |> makeValue None diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index d1549ec3cf..d6a69464ed 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -720,7 +720,30 @@ let constructor com ent = | Some e -> e | None -> $"Cannot find %s{ent.FullName} constructor" |> addErrorAndReturnNull com [] None -let rec private getZero (com: ICompiler) (ctx: Context) (t: Type) = +/// For user-defined value types (structs), returns a constructor call initialized with +/// a zero value for each instance field. Only instance fields are constructor parameters; +/// static fields (from `static let` bindings) are excluded to avoid deep recursion through +/// static field type graphs. Returns None if no constructor is available or the type is +/// not a value type. +let rec private tryZeroValueTypeConstructor (com: ICompiler) (ctx: Context) (t: Type) (ent: Entity) = + if ent.IsValueType then + tryConstructor com ent + |> Option.map (fun e -> + let args = + ent.FSharpFields + |> List.choose (fun f -> + if f.IsStatic then + None + else + getZero com ctx f.FieldType |> Some + ) + + Helper.ConstructorCall(e, t, args) + ) + else + None + +and private getZero (com: ICompiler) (ctx: Context) (t: Type) = match t with | Boolean -> makeBoolConst false | Char -> makeCharConst '\000' @@ -734,29 +757,9 @@ let rec private getZero (com: ICompiler) (ctx: Context) (t: Type) = | Builtin(BclKeyValuePair(k, v)) -> makeTuple None true [ getZero com ctx k; getZero com ctx v ] | ListSingleton(CustomOp com ctx None t "get_Zero" [] e) -> e | DeclaredType(entRef, _) -> - let ent = com.GetEntity(entRef) - // For user-defined value types (structs), call the constructor with zero values - // for each instance field so Array.zeroCreate produces properly initialized instances - // instead of null. Only instance fields are constructor parameters; static fields - // (from `static let` bindings) are not, and iterating them could cause deep recursion - // through static field type graphs. - if ent.IsValueType then - tryConstructor com ent - |> Option.map (fun e -> - let args = - ent.FSharpFields - |> List.choose (fun f -> - if f.IsStatic then - None - else - getZero com ctx f.FieldType |> Some - ) - - Helper.ConstructorCall(e, t, args) - ) - |> Option.defaultValue (Value(Null Any, None)) // null - else - Value(Null Any, None) // null + com.GetEntity(entRef) + |> tryZeroValueTypeConstructor com ctx t + |> Option.defaultValue (Value(Null Any, None)) // null | _ -> Value(Null Any, None) // null let getOne (com: ICompiler) (ctx: Context) (t: Type) = @@ -1008,11 +1011,7 @@ let rec defaultof (com: ICompiler) (ctx: Context) r t = | DeclaredType(entRef, _) -> let ent = com.GetEntity(entRef) // TODO: For BCL types we cannot access the constructor, raise error or warning? - if ent.IsValueType then - tryConstructor com ent - else - None - |> Option.map (fun e -> Helper.ConstructorCall(e, t, [])) + tryZeroValueTypeConstructor com ctx t ent |> Option.defaultWith (fun () -> // Null t |> makeValue None Helper.LibCall(com, "Util", "defaultOf", t, [], ?loc = r) diff --git a/tests/Js/Main/TypeTests.fs b/tests/Js/Main/TypeTests.fs index bd1f155b7f..ac68232210 100644 --- a/tests/Js/Main/TypeTests.fs +++ b/tests/Js/Main/TypeTests.fs @@ -429,6 +429,21 @@ type ClassWithDefaultValueStaticFields() = static member Decimal = ClassWithDefaultValueStaticFields.decimal static member StructTuple2 = ClassWithDefaultValueStaticFields.structTuple2 +[] +type NestedStruct = + + val A : float + val B : bool + + new (a, b) = { A = a; B = b } + +[] +type TopLevelStruct = + + val A : float + val B : NestedStruct + + new (a, b) = { A = a; B = b } type Default1 = int @@ -1600,4 +1615,10 @@ let tests = ClassWithDefaultValueStaticFields.Decimal |> equal 0M ClassWithDefaultValueStaticFields.IncrCount() |> equal 1 + + testCase "Unchecked.defaultof works for fields on structs" <| fun () -> + let top = TopLevelStruct() + top.A |> equal 0 + top.B.A |> equal 0 + top.B.B |> equal false ] diff --git a/tests/Python/TestMisc.fs b/tests/Python/TestMisc.fs index 7a90817529..22002a9ab7 100644 --- a/tests/Python/TestMisc.fs +++ b/tests/Python/TestMisc.fs @@ -1097,6 +1097,7 @@ let ``test Unchecked.defaultof works`` () = Unchecked.defaultof |> equal 0L Unchecked.defaultof |> equal 0I Unchecked.defaultof |> equal 0M + Unchecked.defaultof |> equal '\u0000' Unchecked.defaultof |> equal DateTime.MinValue Unchecked.defaultof |> equal (TimeSpan.FromMilliseconds 0.) Unchecked.defaultof |> equal false diff --git a/tests/Python/TestType.fs b/tests/Python/TestType.fs index 03dae380df..0e817dbdbf 100644 --- a/tests/Python/TestType.fs +++ b/tests/Python/TestType.fs @@ -362,6 +362,18 @@ type TypeWithDefaultValueTest() = [] val mutable ObjValue: System.Collections.Generic.Dictionary +[] +type NestedStruct = + val A: float + val B: bool + new(a, b) = { A = a; B = b } + +[] +type TopLevelStruct = + val A: float + val B: NestedStruct + new(a, b) = { A = a; B = b } + type Default1 = int type Distinct1 = @@ -1757,3 +1769,10 @@ let ``test Different type parameter combinations work correctly`` () = let result3 = getTwoValues() result3 |> equal ("B", "C") + +[] +let ``test Unchecked.defaultof works for fields on structs`` () = + let top = TopLevelStruct() + top.A |> equal 0 + top.B.A |> equal 0 + top.B.B |> equal false diff --git a/tests/Rust/tests/src/MiscTests.fs b/tests/Rust/tests/src/MiscTests.fs index f5bad17395..9b07f9cfaa 100644 --- a/tests/Rust/tests/src/MiscTests.fs +++ b/tests/Rust/tests/src/MiscTests.fs @@ -1196,6 +1196,7 @@ let ``Unchecked.defaultof works`` () = Unchecked.defaultof |> equal 0L Unchecked.defaultof |> equal 0I Unchecked.defaultof |> equal 0M + Unchecked.defaultof |> equal '\u0000' Unchecked.defaultof |> equal DateTime.MinValue Unchecked.defaultof |> equal (TimeSpan.FromMilliseconds 0.) Unchecked.defaultof |> equal false diff --git a/tests/Rust/tests/src/TypeTests.fs b/tests/Rust/tests/src/TypeTests.fs index c773370b3f..bd5e805ba2 100644 --- a/tests/Rust/tests/src/TypeTests.fs +++ b/tests/Rust/tests/src/TypeTests.fs @@ -270,6 +270,18 @@ type StructUnion = Value of string [] type SimpleRecord = { A: string; B: string } +[] +type NestedStruct = + val A: float + val B: bool + new(a, b) = { A = a; B = b } + +[] +type TopLevelStruct = + val A: float + val B: NestedStruct + new(a, b) = { A = a; B = b } + type Point2D = struct val X: float @@ -1108,6 +1120,13 @@ let ``struct without explicit ctor works`` () = t1 |> equal t2 (compare t1 t2) |> equal 0 +[] +let ``Unchecked.defaultof works for fields on nested structs`` () = + let top = TopLevelStruct() + top.A |> equal 0 + top.B.A |> equal 0 + top.B.B |> equal false + [] let ``copying struct records works`` () = // See #3371 let simple : SimpleRecord = { A = ""; B = "B" }