Skip to content

← The Type System as a Design Tool step 8 of 24

Medium Primitives

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 -> int is implicit; int -> Cents requires the explicit Cents(...) call. That call is the boundary marker, and writing it is a feature, not a chore.
  • Arithmetic strips the brand. Cents(100) + 1 has type int, not Cents. 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 a valid-newtype error, as is deriving one NewType from 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.