We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 13 of 25
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 underreplace(). -
init=Falsefields cannot be passed — and, crucially, are not carried over. They are recomputed by__post_init__, or lost. -
An
InitVarwithout 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=Falsefield raisesValueError. CPython 3.14.6 raisesTypeError. 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
-
Complete
EvolveArgswith two optional keys,celsius: floatandscale: str. -
Complete frozen
Reading:celsius: float,scale: InitVar[str] = "c", andfahrenheit: floatas aninit=Falsederived field.__post_init__computes Fahrenheit — from Celsius whenscale == "c", from Kelvin whenscale == "k"— and raisesValueError(f"unknown scale: {scale!r}")otherwise. -
Give
Durationa__replace__socopy.replace(d, seconds=n)works. -
Implement
evolve, andsolve(...)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.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.