Repository navigation
Description
An ordinary F# class can override Equals and GetHashCode without opting into Python's __eq__ and __hash__ protocols. Direct F# equality calls the generated Equals method, but generic hashing and structural values do not consistently use those F# methods.
With Fable 5.11.0:
- direct class equality uses
Equals, buthashcan use Python's identity hash instead ofGetHashCode - a union containing the class compares and hashes its field with Python identity semantics
- record equality uses
Equals, but record hashing uses the field's Python identity hash - array hashing has the same problem for its items
This is related to #4805, but a separate case. #4805 restores __hash__ on generated union case dataclasses. Once hashing is available, Union.GetHashCode still hashes its fields through Python, so an ordinary F# class without Python marker interfaces keeps its identity hash.
Minimal reproduction
Repro.fsproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Core" Version="5.2.0" />
</ItemGroup>
</Project>Program.fs:
module Program
type CustomValue(value: int, label: string) =
member _.Value = value
member _.Label = label
override _.Equals(other) =
match other with
| :? CustomValue as other -> value = other.Value
| _ -> false
override _.GetHashCode() = value
type WrappedUnion =
| WrappedUnion of CustomValue
type WrappedRecord =
{ Value: CustomValue }
let left = CustomValue(1, "left")
let right = CustomValue(1, "right")
let leftUnion = WrappedUnion left
let rightUnion = WrappedUnion right
let leftRecord = { Value = left }
let rightRecord = { Value = right }
let leftArray = [| left |]
let rightArray = [| right |]
printfn "class equality: %b" (left = right)
printfn "class hash: %b" (hash left = hash right)
printfn "union equality: %b" (leftUnion = rightUnion)
printfn "union hash: %b" (hash leftUnion = hash rightUnion)
printfn "record equality: %b" (leftRecord = rightRecord)
printfn "record hash: %b" (hash leftRecord = hash rightRecord)
printfn "array equality: %b" (leftArray = rightArray)
printfn "array hash: %b" (hash leftArray = hash rightArray)Commands:
dotnet new tool-manifest
dotnet tool install fable --version 5.11.0
python -m venv .venv
# Activate the virtual environment using the command for your shell.
python -m pip install fable-library==5.11.0
dotnet run --project Repro.fsproj
dotnet fable Repro.fsproj --lang python --outDir out --noCache
python out/program.pyExpected behavior
.NET prints true for all eight checks. Python should preserve the same F# equality and hash-code contracts:
class equality: true
class hash: true
union equality: true
union hash: true
record equality: true
record hash: true
array equality: true
array hash: true
Actual behavior
The Python target uses the custom equality for the class and record, but not for the union field. Hashing equivalent class instances produces different values, and that identity hash leaks into unions, records, and arrays:
class equality: true
class hash: false
union equality: false
union hash: false
record equality: true
record hash: false
array equality: true
array hash: false
Probable root cause
There are several paths to the same mismatch:
safe_hashchecks for a callable Python__hash__before checking for F#GetHashCode. Ordinary Python objects inheritobject.__hash__, so a generated F# class reaches the identity-hash branch first@tagged_unionappliesdataclass(cls)with generated Python equality. The generated__eq__compares fields with Python==instead of Fable'sequalshelperUnion.Equalsalso comparesself.fields == other.fields, andUnion.GetHashCodehashes a Python tuple containing the fieldsrecord_get_hashcodehashes a Python tuple containing the record fieldsstructural_hashcalls Pythonhashdirectly, andarray_hashapplies it to every item
The TypeScript runtime avoids these native-container paths. Union and record equality call Fable equality helpers, while their hash codes combine the structural hash of each field.
Possible solution 1: correct the existing helpers
Change safe_hash to prefer GetHashCode. Make structural_hash recurse through F# arrays and prefer F# hash methods, then let array_hash use the corrected structural helper. Union and record equality and hashing would call those helpers. Generated union case dataclasses would use eq=False so they inherit Fable's equality implementation, while retaining the __hash__ restoration added by #4805.
This is the smaller implementation. It would also fix already-generated Python code when fable-library is upgraded. The drawback is that it changes the behavior of existing functions in the published Python runtime.
Possible solution 2: add new F#-semantic hash helpers
Keep safe_hash, structural_hash, and array_hash unchanged. Add new helpers, for example generic_hash and generic_array_hash, that prefer GetHashCode and recurse through structural values. Update Replacements.fs, unions, and records to call the new helpers. The union dataclass equality change is still required.
This avoids changing existing runtime entry points. The trade-off is that newly compiled output requires the matching updated fable-library. Previously generated code continues to call the old helpers until it is recompiled.
Activity