Skip to content

← Modelling Data with Dataclasses step 18 of 28

Hard Framework

Convert Dataclasses to JSON-Ready Values

Your API returns an Account as JSON. dataclasses.asdict turns a dataclass into a dictionary, so it looks like the first step. But json.dumps still fails on the result:

import json
from dataclasses import asdict, dataclass
from decimal import Decimal
from uuid import UUID


@dataclass
class Account:
    id: UUID
    balance: Decimal


account = Account(UUID(int=1), Decimal("10.50"))
print(asdict(account))
# {'id': UUID('00000000-0000-0000-0000-000000000001'), 'balance': Decimal('10.50')}
json.dumps(asdict(account))  # raises TypeError (UUID is not JSON serializable)

asdict converts nested dataclasses, lists, tuples and dictionaries, and deep-copies every other value. So a UUID comes out as a UUID. The json module accepts only None, bool, int, float, str, lists and dictionaries. Here you write a converter with one rule for each type, so its output is always ready for json.dumps.

Define JsonValue

Fill in the recursive type alias, as in the exercise Describe Nested JSON with a Recursive Type Alias:

type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]

Convert each value

Write to_jsonable(obj: object) -> JsonValue. Apply the first rule that matches, in this order:

Input Output
an Enum member its value, converted by these same rules
None, bool, int, float or str the value itself
Decimal str(obj), so Decimal("10.50") becomes "10.50"
datetime obj.isoformat()
UUID str(obj)
a Mapping a new dictionary with str(key) keys and converted values
set or frozenset a list of the converted items, sorted by repr
list or tuple a new list of the converted items, in order
a dataclass instance a new dictionary of field names and converted values, in fields() order
anything else raise TypeError(f"cannot serialise {type(obj).__name__}")
  • Check Enum first. An IntEnum member is also an int, and a StrEnum member is also a str. Both must go through their value.
  • is_dataclass is also true for a class such as Address itself. Test is_dataclass(obj) and not isinstance(obj, type) to accept only instances.
  • A NamedTuple is a tuple, so it becomes a plain list.
  • A Decimal becomes a string, because a float would drop the trailing zero and can lose digits.
  • Do not call asdict or deepcopy. Build new dictionaries and lists, and leave the input unchanged.
  • Include every field of a dataclass, whatever its repr, compare or metadata options.

Edge cases

  • Mapping keys go through str(), not through these rules. If two keys give the same string, the later entry replaces the earlier one. Keep the mapping's order.
  • Sort a set by the repr of each converted item, not by value. So {2, 10} becomes [10, 2], because "10" sorts before "2". That gives the same order on every run.
  • Every float in the inputs is finite, so you do not need to handle NaN or infinity.

Detect cycles

A cycle is an object that contains itself, directly or through other objects. The cycle fixture holds two Node objects that point at each other. Without a check, the converter would recurse until Python raises RecursionError.

  • Track the id() of each container and dataclass instance on the current path, from the top object down to the one you are converting.
  • If you reach an object that is already on the path, raise ValueError(f"cycle detected at {type(obj).__name__}").
  • Take an object off the path when you finish converting it. One way is to pass each child call a new frozenset with the object's id() added.
  • Do not keep one set of every object you have visited. The same Address can appear in two branches without a cycle, and you convert it both times.

What solve returns

solve(fixture) calls the factory named fixture in FIXTURES and converts the result. Every fixture name is valid.

  • On success, return {"error": "", "json": converted}.
  • If conversion raises ValueError, return {"error": str(exc), "json": None}. Do not catch TypeError.
solve("address")  # {"error": "", "json": {"city": "", "zip_code": ""}}
solve("enum")     # {"error": "", "json": "free"}
solve("cycle")    # {"error": "cycle detected at Node", "json": None}

In the account fixture, balance becomes "10.50", labels becomes ["beta", "eu"], tags becomes ["a", "b"], and created_at becomes "2026-01-02T03:04:05+00:00". Your code must pass mypy --strict.