Skip to content

Gotchas

Most of dispatch reads exactly the way the type hints suggest it should. This page collects the handful of places where the reasonable-looking guess is wrong, together with the rule that actually applies and a short example of it in action. Each section links to the guide page that covers its topic in full.

An invariant container does not order itself

List[int] and List[bool] describe two different, incomparable kinds of list, because the type variable behind a mutable container such as list is invariant. A plain list declares no type argument of its own, so it matches both overloads equally, and a call passing one is ambiguous rather than resolved in favor of the narrower-looking hint:

>>> import typing_extensions as tx
>>> from bagof.dispatchers import Function, AmbiguousMethodError
>>> handle = Function("handle")
>>> @handle.register
... def _ints(xs: tx.List[int]) -> str:
...     return "ints"
>>> @handle.register
... def _bools(xs: tx.List[bool]) -> str:
...     return "bools"
>>> try:
...     handle([True, False])
... except AmbiguousMethodError:
...     print("ambiguous")
ambiguous

A covariant type variable, such as the one behind Sequence, would order these two the same way it orders bool and int themselves. See Variance for the full account, including how a value that does declare its type argument, such as an instance of a class written against a parametrised base, is matched against it directly.

float means exactly float

Dispatch matches the runtime type of a value, so an overload annotated float accepts only actual float instances. Python's numeric tower, where an int is treated as a kind of float for the purposes of static type checking, is not a rule dispatch follows:

>>> from bagof.dispatchers import dispatch, NoMethodError
>>> @dispatch
... def halve(x: float) -> float:
...     return x / 2
>>> try:
...     halve(3)
... except NoMethodError:
...     print("no overload for int")
no overload for int

Annotate the parameter numbers.Real instead when any real number should do, including an int, a bool, or a fractions.Fraction:

>>> from numbers import Real
>>> @dispatch
... def halve(x: Real) -> float:
...     return x / 2
>>> halve(3)
1.5
>>> halve(2.0)
1.0

Dispatching on numbers covers numbers.Integral and numbers.Complex too.

A protocol is read from declarations, not from what is set right now

A runtime_checkable protocol's data member counts as present the way a static type checker would read it, which is not always the way isinstance would. An annotation declares the member even before anything sets the corresponding attribute, and a read-only member, such as a property with no setter, satisfies a protocol member that could in principle be assigned to:

>>> import typing_extensions as tx
>>> from bagof.dispatchers import dispatch
>>> @tx.runtime_checkable
... class Named(tx.Protocol):
...     name: str
>>> @dispatch
... def greet(x: Named) -> str:
...     return "hello, " + x.name
>>> @dispatch
... def greet(x: object) -> str:
...     return "hello, stranger"
>>> class Guest:
...     pass
>>> guest = Guest()
>>> greet(guest)
'hello, stranger'
>>> guest.name = "Ada"
>>> greet(guest)
'hello, Ada'
>>> class ReadOnlyPerson:
...     @property
...     def name(self):
...         return "read-only"
>>> greet(ReadOnlyPerson())
'hello, read-only'

A ClassVar declaration reads as a separate kind of member, looked up on the class rather than on the instance, and it is never satisfied by a plain class attribute of the same name:

>>> from typing import ClassVar
>>> @tx.runtime_checkable
... class Kinded(tx.Protocol):
...     kind: ClassVar[str]
>>> @dispatch
... def describe(x: Kinded) -> str:
...     return "a " + x.kind
>>> @dispatch
... def describe(x: object) -> str:
...     return "something"
>>> class Cat:
...     kind: ClassVar[str] = "cat"
>>> describe(Cat())
'a cat'
>>> class Dog:
...     kind = "dog"
>>> describe(Dog())
'something'

Both rules follow the typing specification and match how mypy and pyright read the same protocol. Protocols covers what happens next: a class that declares every member of a protocol it satisfies becomes the more specific overload, while a class that declares none of them is left ambiguous against it.

An ambiguous call is always an error

A call that matches two equally specific overloads never picks one and stays quiet about it. It raises AmbiguousMethodError, and there are two ways to prevent that from happening in the first place. Exact excludes an overload's subclasses when a handler written for the base type must not also catch a more specific one, such as bool being a subclass of int:

>>> from bagof.dispatchers import dispatch, Exact
>>> @dispatch
... def label(x: Exact[int]) -> str:
...     return "an integer"
>>> @dispatch
... def label(x: object) -> str:
...     return "something else"
>>> label(5)
'an integer'
>>> label(True)
'something else'

priority breaks a tie between two overloads that are, and are meant to stay, equally specific at the type level:

>>> from bagof.dispatchers import Function
>>> combine = Function("combine")
>>> @combine.register((float, object), priority=1)
... def _(a, b): return "left"
>>> @combine.register((object, float))
... def _(a, b): return "right"
>>> combine(1.0, 2.0)
'left'

See When two overloads are equally specific and Exact types for more on each.

A declared parametrisation drives dispatch, not a container's contents

A class written against a parametrised generic base, such as class GL(list[T]) on Python 3.9 and later, dispatches by the type argument it was built with. That argument is read once, from how the instance was constructed, and never by looking at the values the instance holds. GL[int] and GL[bool] both count as declaring List[int] and List[bool] respectively, so instances built from them settle the ambiguity that a plain list leaves standing. Calling handle, the same function registered above for List[int] and List[bool], with each one now picks the overload its declared argument calls for:

T = tx.TypeVar("T")

class GL(list[T]):
    pass

handle(GL[int]([1, 2]))     # 'ints'
handle(GL[bool]([True]))    # 'bools'

A plain [1, 2] still matches every parametrisation equally, whatever values it holds, because a plain list never declares a type argument of its own. Variance covers declared parametrisations in detail, including what happens when a class declares two conflicting ones through separate bases.