Union not compatible with constrained TypeVar

Could someone explain why this does not typecheck? Is this a legit error or a limitation in the type checkers? Is there a way to make this work?

from typing import TypeVar, Union

class A:...
class B:...

 # A generic function
P = TypeVar('P', A, B)
def dostuff(value: P) -> P:
    return value

# Another function that returns any of the types that constrain P
U = Union[A, B]
def make_object() -> U:
    return A()

dostuff(A()) # OK
dostuff(B()) # OK

obj = make_object()
dostuff(obj)  # Does not typecheck

mypy

Value of type variable "P" of "dostuff" cannot be "Union[A, B]"  [type-var]

pyright

Argument of type "U" cannot be assigned to parameter "value" of type "P@dostuff" in function "dostuff"
  Type "U" is incompatible with constrained type variable "P"

Constrained typevars require that the value has to be exactly one of the types, it doesn’t allow subclasses, while unions do. Either mark both classes with @final, or make the typevar bound=U.

Constrained typevars require that the value has to be exactly one of the types, it doesn’t allow subclasses,

I don’t think that is true. If we add a subclass it still type checks.

class C(B):...

c = C()
dostuff(c) # OK

The constrained type variable means that when you call dostuff, you can do so with an A value (and P will be bound to A), and you can do so with a B value (and P will be bound to B). What you can’t do is provide a value that has unknown type, even if it’s one of A or B without being certain which it is (which is what A | B means).

1 Like

The static type of the argument has to be one of A or B (or a subtype of either). A | B is a supertype of both A and B, but a subtype of neither.

But is there a typing theoretic reason to not understand that all values from U can be bound to P and thus mark it as safe?

Strictly speaking, run-time type identification isn’t an available operation. If you only know a value has type A|B, the only operations available are the ones available for type A and for type B.

One way to think of this is that dostuff has type Callable[[A], A] | Callable[[B], B]. You get to see the type of obj before deciding which half of the union to use, but you don’t get to look at the value of obj before deciding.

Strictly speaking, run-time type identification isn’t an available operation. If you only know a value has type A|B , the only operations available are the ones available for type A and for type B .

I don’t think there is runtime involved, this is about statically introspecting the provided types. The question I think is: is there a situation under which allowing for this would cause a runtime error? I don’t see how.

There may not be a problem at runtime, but that doesn’t mean the type system can accurately describe the situation. In isolation, you might be able to say that T’s type doesn’t matter and that a union of its constraints would be OK.

But consider something more general, like

def f1() -> A|B:
    return A()

def f2() -> A|B:
    return B()

def f3(x: T, y: T) -> T:
    ...

f3(f1(), f2())

f3 may be relying on both arguments having the same type, but here they are clearly different.

2 Likes

Good example! It shows why accepting it would be unsafe for multiple inputs.
I wish it were tolerated in some special cases as the one I shared, but I can see why they did not go for it.