We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 8 of 25
Serializing dataclasses properly: round-trips, paths and schema versions
“How do we turn our dataclasses into JSON” is answered badly almost everywhere,
and the bad answer is always the same: json.dumps(asdict(obj), default=str).
It is slow (asdict deep-copies every non-atomic value). It is lossy — a
Decimal("10.50") becomes the string "10.50" and nothing on the other side
knows to turn it back, so your money is now text. And it is one-directional:
there is no asdict inverse, which is precisely why dict-shaped data in so many
codebases never becomes typed objects. The types stop at the boundary and Any
flows the rest of the way in.
The three real options
-
cattrs — annotation-driven
structure/unstructurehooks, converters cached per class, works on stdlib dataclasses and attrs, and — the reason to reach for it — it does deserialization. If you want one dependency for this problem, it is this one. -
pydantic.TypeAdapterover a plain dataclass — validation and JSON Schema without makingBaseModelyour domain type. - Hand-written — zero dependency, right for a small, stable schema, and the only one where you can see every decision. That is what you are writing here.
Cross-cutting, whichever you choose:
-
Field.metadatais the sanctioned place for per-field hints — it is documented as a third-party namespace, and it is how every codec attaches “serialise this one differently” without polluting the type. - Version your serialized records and write explicit upgrade functions. A reader that guesses is a reader that silently mis-parses.
-
The round-trip property is the single highest-value test you can write for a
value object:
load(dump(x)) == x, for arbitraryx. It catches lossy conversions, field-order assumptions, default drift and enum mismatches in one assertion.
Your task
Implement Codec[T] over the frozen Account/Address tree, driven by
fields() and Field.type — no hardcoded field lists.
dump(obj) -> JsonValue: dataclass → dict in field order; datetime →
.isoformat(); Decimal → str; UUID → str; Enum → its .value; tuple
→ list; None/bool/int/str unchanged. No deep copies.
load(raw) -> T: the inverse, dispatching on the annotation:
-
str,int(rejectingbool),UUID,datetime(fromisoformat),Decimal; -
an
Enumsubclass, by value; -
tuple[X, ...]from a JSON list; -
X | None—Nonepasses through, anything else loads asX; - a nested dataclass, recursively.
Errors are CodecError(path, problem), whose message is f"{problem}:{path}"
and whose path is dotted (address.zip_code):
| situation | problem |
|---|---|
| a key that is not a field |
unknown_key (path names the key) |
| a missing key with no default |
missing |
| wrong JSON type |
expected_str / expected_int / expected_list / expected_object |
| unparseable UUID/datetime/Decimal |
malformed |
| an enum value that does not exist |
unknown_enum |
Then upgrade_v1(raw) — v1 called the timestamp created, spelled the postcode
zip, and had no tier (default "free") — and solve(raw, version) returning
{"error", "dump", "roundtrip"}, where roundtrip is load(dump(x)) == x.
Two implementation notes. Read annotations from f.type, not
get_type_hints — on 3.14 an unquoted annotation is already the resolved object,
and f.type works without needing the defining module’s globals. And to
construct a T bound to the stub-only DataclassInstance, assign the class to a
Callable[..., T] first: type[T] where T is bound to a Protocol is not
directly callable in mypy’s model, and the Callable view is the clean way to
say “this class is a factory for T” without a # type: ignore.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.