Search code examples
pythonpython-typingtype-variables

python typing TypeVar: understanding unbound error


I do not understand error like

Type variable "T" is unbound

Here a concrete example:

from typing import Dict, Type, Callable, TypeVar

class Sup: ...
class A(Sup): ...
class B(Sup): ...

def get_A(a: A) -> int:
    return 1

def get_B(b: B) -> int:
    return 2

T = TypeVar("T", bound=Sup)

f: Dict[Type[T], Callable[[T], int]] = {A: get_A, B: get_B}

mypy returns:

error: Type variable "foo.T" is unbound  [valid-type]
note: (Hint: Use "Generic[T]" or "Protocol[T]" base class to bind "T" inside a class)
note: (Hint: Use "T" in function signature to bind "T" inside a function)
error: Dict entry 0 has incompatible type "type[A]": "Callable[[A], int]"; expected "type[T?]": "Callable[[T], int]"  [dict-item]
error: Dict entry 1 has incompatible type "type[B]": "Callable[[B], int]"; expected "type[T?]": "Callable[[T], int]"  [dict-item]

My understanding (which may be incorrect), is that mypy complains because it can not guarantee that later on, no tuple (key, value) not following the constrain on T will be added. For example:

# not a subclass of Sup !
class D: ...

def get_D(d: D)->int:
  return 2

f[D] = get_D  # D is not a subclass of Sup, so violation of the type of f

Question:

  • is my understanding of the error correct ? If not, could an explanation be provided ?

  • if my understanding is correct, why would the error occur here:

f: Dict[Type[T], Callable[[T], int]] = {A: get_A, B: get_B}

and not here ?

# This is the line not respecting the type of f !
f[D] = get_D 

Solution

  • TypeVar binding

    First of all, what does "Type variable "T" is unbound" mean? Let's clarify things, since it has nothing to do with its bound argument.

    When you use a type variable, you assign it some scope. This is not the runtime scope (global) where you define a T = TypeVar('T'). In a generic class, the variable is bound by Generic[T] (or Protocol[T], or some other class that is already generic) base class: when you use this form, you can use this type variable within the class body, so it's class-scoped. In a generic function, the variable is bound merely by inclusion into signature and is function-scoped. Here's another snippet that produces the same error (playground):

    from typing import TypeVar
    
    _T = TypeVar("_T")
    
    def fn() -> int:
        x: _T = 2
        return 1
    

    This error does not relate to such complicated matters as you consider: you are simply not allowed to use a type variable that is not assigned a scope (bound).

    Only generic functions and classes bind type vars for their bodies, and also a type alias can use free type vars in its right-hand side (strictly speaking, that isn't "use in annotation" since that part is a plain runtime assignment).

    This should be more clear with PEP695 generic syntax (I'm not a big fan of it, but can't argue it makes scoping more clear): you can declare a type var for function, for class and for a type alias. You cannot declare a type var for anything else.

    def fn[T](x: T) -> T:
        y: T = x
        # You can use T here
    
    class Foo[T]:
        foo: T  # You can use T here
    
        def __init__(self, x: T) -> None:
            y: T = x  # And here (class-scoped, but also present in the func)
        
        def foo(self) -> None:
            y: T = self.foo  # But also here - you already "allocated" T for the class
    
    type Bar[T] = dict[T, T]
    
    # All three T's above are different
    # And you can't use T here
    x: T  # Typechecker error!
    

    Note how the type variable is created for some scope (function/class/type alias). You couldn't rewrite your example using 3.12 syntax - and no, it isn't because new syntax isn't powerful enough. That just isn't supported by python's type system.

    if my understanding is correct, why would the error occur here:

    f: Dict[Type[T], Callable[[T], int]] = {A: get_A, B: get_B}
    

    Because it's the place where you use a type variable not bound to current scope.

    Why are all other uses rejected?

    Because of scoping! Consider your idea (simplified - callable is irrelevant, right?):

    T = TypeVar("T")
    
    x: dict[T, T]
    

    If I understand correctly, you want it to be interpreted like this (forgetting about all other methods, see typeshed for a wall of them):

    _K = TypeVar("_K")
    _V = TypeVar("_V")
    
    class dict(Generic[_K, _V]):
        def __getitem__(self, key: _K, /) -> _V: ...
        def __delitem__(self, key: _K, /) -> None: ...
    
    
    # dict[T, T] is equivalent to substituting T for both _K and _V
    class YourDict(Generic[T]):
        def __getitem__(self, key: T, /) -> T: ...
        def __delitem__(self, key: T, /) -> None: ...
    

    So YourDict is something equivalent to dict[T, T] using your syntax.

    Let's unwrap this: T in YourDict is class scoped. It is the same for all places where it's mentioned inside the class body. At this point, type checkers can forget about the fact that __getitem__ could also be a generic function outside of class context, because it cannot "own" that T. Compare with __delitem__ where T would make no sense at all shall we "forget" about class binding. So, there could be YourDict[int] or YourDict[str] - types that have __getitem__ accepting and returning int or str, resp., same as dict[int, int].

    There is no way to express "substitute generic's typevar with another typevar, but only method-scoped" - mostly because this use case is pretty niche and semantics would be terribly difficult to define (what should happen to methods with only one typevar reference - __delitem__ above?).

    You might be inspired by this example similar to one from PEP484 (references below):

    T = TypeVar('T')
    S = TypeVar('S')
    
    class Foo(Generic[T]):
        def __init__(self, x: T) -> None:
            self._x = x
    
        def method(self, x: T, y: S) -> S: ...
    
    x = Foo(1)  # Foo[int]
    y = x.method(0, "abc")  # inferred type of y is str
    

    Recommended reading