Skip to content

← Everyday Standard Library Tools step 31 of 55

Hard Primitives

Build a JSON Converter Other Code Can Extend

Write a converter that turns Python values into JSON-friendly ones. Build it with singledispatch, so other code can add a type without editing it, and test that with a Money class.

to_json_value([1, Decimal("0.25"), True])  # [1, "0.25", 1]
solve(["mixed:", "money:250", "mystery"], register_extra=True)
# [[1, "0.25", 1], {"cents": 250}, "TypeError"]

What to write

Make to_json_value(value: object) -> object a module-level @singledispatch function whose base version raises TypeError. Register these handlers:

Type Result
int int(value), so True and False become 1 and 0
str The string unchanged
Decimal str(value), which keeps formatting such as "1.50"
datetime value.isoformat()
list A new list with each item converted
dict A new dict with str(key) keys and converted values. If two keys give the same string, the later value wins.

Register list and dict by passing the class, as in to_json_value.register(list, _list_to_json), and keep annotations such as list[object] on those handlers.

Then write solve(specs: list[str], register_extra: bool) -> list[object]. Split each spec at its first colon into a kind and a payload, build the value below, and convert it. Keep the input order. If the conversion raises TypeError, append the string "TypeError" and go on.

Spec Value to build
int:42 42
str:hello:world "hello:world"
bool:true, bool:false True, False
decimal:1.50 Decimal("1.50")
datetime:<iso> datetime.fromisoformat(iso)
list:1,2,3 [1, 2, 3]
dict:a=1,b=2 {"a": 1, "b": 2}
mixed: [1, Decimal("0.25"), True], whatever the payload
money:250 A Money instance with cents == 250
any other kind A Mystery() instance

Define Money, with a cents attribute, and Mystery inside solve, so that each call makes fresh classes. When register_extra is true, register a handler for Money that returns {"cents": value.cents}. Otherwise Money has no handler and converts to "TypeError".

The tests check only the output, but the lesson is the dispatcher, so replace the starter's isinstance chain.

Edge cases

  • An empty specs list gives [].
  • A spec with no colon has an empty payload. Its kind is unknown, so it builds a Mystery.
  • Skip empty entries in list and dict payloads, so list: gives []. Keys in a dict payload contain no comma or equals sign, and a repeated key keeps its last value.
  • Every payload for a known kind is valid, and a bool payload is always true or false.
  • A Money handler from an earlier call does not match the next call's Money class. The old handler stays in the registry, which would leak memory in a long-running service.