Suppose I have an interface Base
with a lot of implementations
from abc import ABC
class Base(ABC): ...
class A(Base): ...
class B(Base): ...
class C(Base): ...
# ...
class Z(Base): ...
Now I want to define a composite class that holds a frozenset of such objects. There is a common interface Product
and two implementations which take either a heterogeneous frozenset (MixedProduct
) or a homogeneous frozenset of Z
s (ZProduct
)
from abc import ABC, abstractmethod
from dataclasses import dataclass
class Product(ABC):
@property
@abstractmethod
def items(self) -> frozenset[Base]: ...
@dataclass(frozen=True)
class MixedProduct(Product):
items: frozenset[Base]
@dataclass(frozen=True)
class ZProduct(Product):
items: frozenset[Z]
there is a factory function that takes an arbitrary number of Base
objects and returns the correct Product
object
from collections.abc import Iterable
from typing_extensions import TypeGuard
def check_all_z(items: tuple[Base, ...]) -> TypeGuard[tuple[Z, ...]]:
return all([isinstance(item, Z) for item in items])
def make_product(*items: Base) -> MixedProduct | ZProduct:
# `items` is a tuple[Base, ...]
if check_all_z(items): # the TypeGuard tells MyPy that items: tuple[Z, ...] in this clause
return ZProduct(frozenset(items))
return MixedProduct(frozenset(items))
so this function returns a ZProduct
only if all input items are Z
and MixedProduct
otherwise. Now I would like to narrow the return type of make_product
as a Union doesn’t capture the feasible input – return type relations. What I want would be sth like this
reveal_type(make_product(Z())) # note: Revealed type is "ZProduct"
reveal_type(make_product(A())) # note: Revealed type is "MixedProduct"
reveal_type(make_product(Z(), Z())) # note: Revealed type is "ZProduct"
reveal_type(make_product(B(), A())) # note: Revealed type is "MixedProduct"
reveal_type(make_product(B(), Z())) # note: Revealed type is "MixedProduct" # also contains one Z!!
I go ahead and define two overloads
from typing import overload
@overload
def make_product(*items: Base) -> MixedProduct: ...
@overload
def make_product(*items: Z) -> ZProduct: ...
def make_product(*items):
if check_all_z(
items
): # the TypeGuard tells MyPy that items: tuple[Z, ...] in this clause
return ZProduct(frozenset(items))
return MixedProduct(frozenset(items))
so the first overload is the “catch all” while the second one is the specialization for the only case where you would get a ZProduct
. But now MyPy complains with
error: Overloaded function signature 2 will never be matched: signature 1's parameter type(s) are the same or broader [misc]
So my question is, is there a way to just specialize the annotations for make_product
for this one particular case that would return ZProduct
in any other way? With overload
it seems to only be possible if all the involved types have no overlaps whatsoever. That would mean I would have to define a Union of all other implementations of Base
except Z
and use that as input for the MixedProduct
variant. But that also doesn’t work, because you can have Z
in the input items for the MixedProduct
variant, just not all of them (see last reveal_type example above). FWIW using a Union of all implementations of Base
(including Z
) for the MixedProduct
variant throws the same MyPy error.
How else would I be able to differentiate between homogeneous and heterogeneous tuples with type annotations to capture the correct input – return type relations in my case?
To be clear: the actual runtime code does what I intend, I just can’t get the type annotations right.