Skip to content
← All articles

singledispatch: open extension, and the exhaustiveness you give up

The alternative is a growing isinstance ladder that every new type must edit — a closed set masquerading as an open one. The cost is that no checker can prove your dispatch is total.

Serialisers, renderers, formatters and validators all start the same way:

def to_json_value(value: object) -> object:
    if isinstance(value, str):
        return value
    if isinstance(value, int):
        return int(value)
    if isinstance(value, Decimal):
        return str(value)
    raise TypeError(...)

This is a closed set masquerading as an open one. Every new type means editing this function — including types owned by other teams, other packages, or users of your library who cannot edit it at all. The ladder grows, the ordering between branches becomes load-bearing (bool before int, datetime before date), and nobody can extend it from outside.

functools.singledispatch inverts it:

from functools import singledispatch

@singledispatch
def to_json_value(value: object) -> object:
    raise TypeError(f"no encoding for {type(value).__name__}")

@to_json_value.register
def _(value: Decimal) -> object:
    return str(value)

Registration by annotation has worked since 3.7, and Union in the annotation since 3.11. Dispatch is on the runtime class of the first argument, using the MRO — so a subclass falls through to its base’s implementation automatically, which is why bool lands on the int handler with no extra work.

The registry is introspectable: to_json_value.registry maps types to implementations, and to_json_value.dispatch(SomeType) tells you which implementation would be chosen. That is genuinely useful in a test — you can assert your handler is reachable without invoking it.

💡@to_json_value.register with an annotation of list[int] click to reveal

raises at import time. Why, and what do you write instead? Because register requires a real class to key the registry on, and list[int] is a types.GenericAlias, not a type. Dispatch happens on type(arg) at runtime, which is always the erased list — there is nothing in the object to distinguish a list[int] from a list[str].

So you register the bare list and use the explicit two-argument form to keep a precise annotation on the implementation:

def _list_to_json(value: list[object]) -> object:
    return [to_json_value(item) for item in value]

to_json_value.register(list, _list_to_json)

Now the registry key is list and the function still type-checks under --disallow-any-generics, which would reject a bare list annotation.

The general point: singledispatch dispatches on runtime classes, so it can never distinguish types that are erased at runtime — generics, Literals, TypedDicts, protocols, unions of non-classes.

What you give up

Dispatch is a runtime mechanism, so the type checker cannot prove your coverage is complete:

to_json_value(object())     # type-checks; raises at runtime

The base implementation’s signature accepts object, which is what makes the function open — and it is exactly what stops the checker from telling you a case is missing.

Contrast the closed alternative:

def render(shape: Circle | Square) -> str:
    match shape:
        case Circle():
            return "circle"
        case Square():
            return "square"
        case _ as unreachable:
            assert_never(unreachable)

Add Triangle to the union and mypy fails the assert_never line, naming the case you forgot. That is a genuinely stronger guarantee — and it only works because the set is closed.

The trade-off is the lesson. Open extension and static exhaustiveness are mutually exclusive, and you have to pick per use case. A serialiser that third-party types must plug into wants singledispatch. A state machine with five states that you own wants match and assert_never.

singledispatchmethod, and a 3.15 change

singledispatchmethod is the same mechanism for methods; it dispatches on the first argument after self. Note a real behaviour change in 3.15: it now supports non-descriptor callables, and when wrapping a regular method accessed as a class attribute it dispatches on the second argument. If you write one, pin down which argument it keys on with a test.

💡Your singledispatch function has handlers for Sequence and click to reveal

for str. Which one handles "abc", and is that stable? str wins — dispatch walks the MRO of type(arg) and str is more derived than any ABC it is registered against. But the interesting part is what happens with two ABCs.

If a class is registered against two unrelated ABCs that a value satisfies (say Sized and Iterable, for a list), the MRO gives no ordering between them and singledispatch raises RuntimeError: Ambiguous dispatch. It refuses to guess, which is the right call and a nasty surprise at import-time scale.

Practical consequence: register concrete classes where you can, register at most one ABC per “axis”, and if you must register ABCs, add a test that calls dispatch() for each concrete type you care about. Ambiguity is detected at call time, not at registration time, so without such a test the failure ships.