Skip to content

← Stdlib Mastery step 27 of 55

Hard Primitives

singledispatch: a JSON encoder callers can extend from outside

Replace an isinstance ladder with an open, extensible dispatcher.

Build a module-level generic function:

@singledispatch
def to_json_value(value: object) -> object: ...

whose base implementation raises TypeError, with registered handlers for:

type result
int the integer
str the string
Decimal str(value)
datetime value.isoformat()
list each element converted, as a list
dict {str(key): converted}

Then:

def solve(specs: list[str], register_extra: bool) -> list[object]:

Each spec is "<kind>:<payload>", built into a Python object and passed through to_json_value; append the result, or the string "TypeError" if it raises.

spec object
"int:42" 42
"str:hello" "hello"
"bool:true" / "bool:false" True / False
"decimal:1.50" Decimal("1.50")
"datetime:<iso>" datetime.fromisoformat(iso)
"list:1,2,3" [1, 2, 3] (ints; empty payload gives [])
"mixed:" [1, Decimal("0.25"), True]
"dict:a=1,b=2" {"a": 1, "b": 2} (int values)
"money:250" a Money class defined inside solve
anything else a Mystery class defined inside solve, never registered

When register_extra is true, register a handler for Money returning {"cents": value.cents}from inside solve, without touching the module-level definitions. That is the whole point of the exercise: a caller who cannot edit your source can still teach your encoder a new type.

Note "bool:true"bool is a subclass of int, so it dispatches to the int handler through the MRO with no extra registration. That is dispatch doing the work the ladder’s branch ordering used to do by hand.

What you are giving up, and it is the lesson. Dispatch is a runtime mechanism, so no checker can prove your coverage is complete: to_json_value(object()) type-checks and raises. The closed alternative — match over a closed union with typing.assert_nevercan be proven exhaustive, and mypy will name the case you forgot when you extend the union. Open extension and static exhaustiveness are mutually exclusive; pick per use case.

A registration detail. register keys the registry on a real class, so an annotation of list[int] raises at import time — list[int] is a GenericAlias, not a type, and dispatch only ever sees the erased list anyway. Register the bare list with the explicit two-argument form so the implementation can still carry a precise annotation that survives --disallow-any-generics.

Loading visualization…