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.