diff --git a/src/fable-library-py/fable_library/union.py b/src/fable-library-py/fable_library/union.py index db30dc1517..b92493854d 100644 --- a/src/fable-library-py/fable_library/union.py +++ b/src/fable-library-py/fable_library/union.py @@ -126,11 +126,13 @@ def tagged_union(tag: int): Uses @dataclass_transform() so type checkers understand: - Field annotations become constructor parameters - __match_args__ is generated - - __eq__, __repr__, __hash__ are generated + - __eq__, __repr__ are generated Additionally sets: - cls.tag = tag (numeric case discriminator) - cls.fields property (list of field values for backwards compat) + - cls.__hash__ from HashableBase (dataclass sets it to None when + generating __eq__, which would make union values unhashable) """ def decorator[T](cls: type[T]) -> type[T]: @@ -148,6 +150,12 @@ def fields(self) -> Array[Any]: return Array[Any]([getattr(self, name) for name in field_names]) dc_cls.fields = fields + + # dataclass() generates __eq__ and therefore sets __hash__ to None. + # Restore the hash protocol so union values stay hashable via + # GetHashCode, e.g. when a union is a field of another union. + dc_cls.__hash__ = HashableBase.__hash__ + return dc_cls return decorator diff --git a/src/fable-library-py/tests/test_union.py b/src/fable-library-py/tests/test_union.py new file mode 100644 index 0000000000..b1a3121623 --- /dev/null +++ b/src/fable-library-py/tests/test_union.py @@ -0,0 +1,45 @@ +from fable_library.union import Union, tagged_union + + +class _Shape(Union): + @staticmethod + def cases() -> list[str]: + return ["Circle", "Boxed"] + + +@tagged_union(0) +class Shape_Circle(_Shape): + radius: int + + +@tagged_union(1) +class Shape_Boxed(_Shape): + inner: _Shape + + +def test_union_case_is_hashable() -> None: + # dataclass() generates __eq__, which sets __hash__ to None unless it is + # restored -- union values must keep supporting the hash protocol. + assert isinstance(hash(Shape_Circle(1)), int) + + +def test_equal_unions_have_equal_hashes() -> None: + assert Shape_Circle(2) == Shape_Circle(2) + assert hash(Shape_Circle(2)) == hash(Shape_Circle(2)) + + +def test_nested_union_is_hashable() -> None: + # A union hashes over its fields, so a union nested as a field of another + # union must be hashable as well. + nested = Shape_Boxed(Shape_Circle(3)) + assert nested.GetHashCode() == Shape_Boxed(Shape_Circle(3)).GetHashCode() + assert hash(nested) == hash(Shape_Boxed(Shape_Circle(3))) + + +def test_union_works_in_sets() -> None: + # set([...]) instead of a set literal: type checkers model tagged_union case + # classes as non-frozen dataclasses (statically unhashable), while the + # decorator restores __hash__ at runtime. + distinct = set([Shape_Circle(1), Shape_Circle(1), Shape_Boxed(Shape_Circle(1))]) + assert len(distinct) == 2 + assert Shape_Circle(1) in distinct