We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 8 of 24
Amortise cents without mixing up the units
transfer(amount_cents, user_id)
called, one refactor later, as
transfer(user_id, amount_cents)
Both are int. Both type-check. The money goes to the wrong place, and the
only thing that catches it is production.
NewType fixes this for zero runtime cost:
Cents = NewType("Cents", int)
UserId = NewType("UserId", int)
Cents(100) is 100 — at runtime Cents is a function that returns its
argument, and since 3.10 it is a class with __call__ optimised to near-free.
To the checker, Cents is a distinct subtype of int: a Cents may be
passed where an int is wanted, but an int may not be passed where a Cents
is wanted, and a Millis may never be passed where a Cents is wanted.
Three rules that catch people out:
-
It is one-directional.
Cents->intis implicit;int->Centsrequires the explicitCents(...)call. That call is the boundary marker, and writing it is a feature, not a chore. -
Arithmetic strips the brand.
Cents(100) + 1has typeint, notCents. Any function returning branded values has to re-wrap the results of its own arithmetic. This is the single most common surprise, and it is why the return type of the function below matters so much. -
The supertype must be a proper class.
NewType("X", int | str)is avalid-newtypeerror, as is deriving oneNewTypefrom another’s union.
The task
Cents = NewType("Cents", int)
Millis = NewType("Millis", int)
def amortise(total: Cents, periods: int) -> list[Cents]:
def to_millis(amount: Cents) -> Millis:
def solve(total: int, periods: int) -> tuple[list[int], list[int]]:
amortise splits total into exactly periods parts that sum exactly to
total, distributing the remainder to the earliest parts. ValueError with
the message "periods must be >= 1" when periods < 1.
The implementation is one divmod:
base, remainder = divmod(total, periods)
The first remainder parts get base + 1; the rest get base. Because
divmod uses floor semantics, this is exact for negative totals too:
divmod(-100, 3) is (-34, 2), so -100 over three periods is
[-33, -33, -34], which still sums to -100.
to_millis converts one Cents to Millis (times ten). solve brands the
incoming plain int, calls amortise, and returns two lists of plain int:
the cent parts and their millis equivalents.
Why the wrapping and unwrapping are both explicit
solve receives an int from outside your program and must write Cents(total)
to bring it into the branded world. It returns list[int], so it must write
int(part) — or a comprehension — to leave it. Those two lines are the entire
value proposition: everything between them is checked, and the two conversions
are the only places a mix-up can happen. Compare with the version where
amortise just takes an int: there is no boundary, so there is nowhere to
look.
Note also that list[Cents] is not assignable to list[int], because
list is invariant. That is not NewType being awkward; it is the same
soundness rule that makes list[Dog] not a list[Animal]. If you want to hand
the caller a list[int], build one.
What the tests check
100 over three periods is [34, 33, 33], not [33, 33, 34] and not
[33, 33, 33]. periods == 1 returns the total unchanged. Zero splits into
zeros. More periods than cents produces leading ones and trailing zeros. And the
negative cases pin the divmod semantics — if you special-cased the sign with
abs(), they will not match.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.