Skip to content

← Data Modelling and Invariants step 13 of 25

Hard Framework

replace(), copy.replace(), and the mypy gap between them

In a frozen-dataclass codebase, replace() sits on every write path. Every state transition is order = replace(order, status=Status.PAID). So a typo’d field name that sails past --strict is not a cosmetic bug; it is a quiet data-integrity bug on the hot path.

What replace() actually does

It calls type(obj).__init__ with the surviving fields plus your changes. That one sentence explains everything else:

  • __post_init__ re-runs, so invariants are re-established and derived fields are recomputed. This is a feature: a frozen value object cannot go stale under replace().
  • init=False fields cannot be passed — and, crucially, are not carried over. They are recomputed by __post_init__, or lost.
  • An InitVar without a default must be re-supplied; one with a default silently reverts to that default. If the InitVar changed how the object was built, replace() un-changes it. This is the sharpest edge in the whole API and it is invisible in the repr.
  • A documentation discrepancy worth knowing: the docs say passing an init=False field raises ValueError. CPython 3.14.6 raises TypeError. Catch the one that actually happens.

copy.replace() and __replace__ (3.13)

3.13 generalised the idea: copy.replace(obj, **changes) calls obj.__replace__, which @dataclass now generates, and which any class can implement. That is genuinely good — datetime, NamedTuple-alikes and your own value objects all get one protocol.

But here is the punchline. mypy has a plugin that special-cases dataclasses.replace: it checks the field names and the field types, even through generic parameterisation. copy.replace has no such plugin, and its stub is **changes: Any. So:

dataclasses.replace(user, age="four")     # error: Argument "age" has incompatible type
dataclasses.replace(user, nonexistent=1)  # error: Unexpected keyword argument
copy.replace(user, age="four")            # no error at all
copy.replace(user, nonexistent=1)         # no error at all

The newer, more general, more elegant API is the less type-safe one. Until that gap closes, prefer dataclasses.replace for dataclasses and keep copy.replace for the cases only it can serve.

The typed wrapper

The way to get both generality and checking is to declare the change set:

class EvolveArgs(TypedDict, total=False):
    celsius: float
    scale: str

def evolve(obj: Reading, changes: EvolveArgs) -> Reading:
    return dataclasses.replace(obj, **changes)

Now every call site is checked against a named, documented, versionable set of mutable keys — and the set is deliberately smaller than the field list, which is usually what you want on a write path.


Your task

  1. Complete EvolveArgs with two optional keys, celsius: float and scale: str.
  2. Complete frozen Reading: celsius: float, scale: InitVar[str] = "c", and fahrenheit: float as an init=False derived field. __post_init__ computes Fahrenheit — from Celsius when scale == "c", from Kelvin when scale == "k" — and raises ValueError(f"unknown scale: {scale!r}") otherwise.
  3. Give Duration a __replace__ so copy.replace(d, seconds=n) works.
  4. Implement evolve, and solve(...) returning "error" (the exception class name, or ""), "celsius", "fahrenheit", "base_fahrenheit" (the value before evolving) and "duration".

Watch what the tests assert. Building a Reading in Kelvin and then evolving it with no changes at all does not give you back the same object: scale is an InitVar, it is not stored, and replace() re-supplies its default, so the reading is reinterpreted as Celsius and fahrenheit changes. Nothing warns you. That is why init=False + InitVar is a combination to use deliberately, and to test.