Skip to content

← Data Modelling and Invariants step 8 of 25

Hard End-to-End

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/unstructure hooks, 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.TypeAdapter over a plain dataclass — validation and JSON Schema without making BaseModel your 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.metadata is 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 arbitrary x. 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(); Decimalstr; UUIDstr; Enum → its .value; tuple → list; None/bool/int/str unchanged. No deep copies.

load(raw) -> T: the inverse, dispatching on the annotation:

  • str, int (rejecting bool), UUID, datetime (fromisoformat), Decimal;
  • an Enum subclass, by value;
  • tuple[X, ...] from a JSON list;
  • X | NoneNone passes through, anything else loads as X;
  • 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.