Skip to content

← Precise Type Hints with Literals and Generics step 8 of 26

Medium Primitives

Keep Cents and Millis Apart with NewType

A billing service splits a charge into equal monthly payments. Amounts are stored as whole cents, and another part of the system uses millis, which here means thousandths of the currency, not milliseconds. One cent is ten millis. Both are plain int values, so nothing stops you passing millis where cents belong. NewType makes mypy catch the mistake.

assert solve(100, 3) == ([34, 33, 33], [340, 330, 330])
assert solve(-100, 3) == ([-33, -33, -34], [-330, -330, -340])
assert solve(7, 1) == ([7], [70])

What NewType does

from typing import NewType

Cents = NewType("Cents", int)
Millis = NewType("Millis", int)

NewType gives an existing type a new name that mypy treats as a separate type. At runtime it is the same value, so Cents(5) is the int 5. mypy then accepts a Cents where an int is expected. It rejects a plain int or a Millis where a Cents is expected.

Arithmetic on Cents gives a plain int. So return amount * 10 in a function that returns Millis fails:

Incompatible return value type (got "int", expected "Millis")  [return-value]

Write Millis(amount * 10) to mark the result. The multiplication does the conversion. Millis(...) only tells mypy which unit the number is in.

What to write

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 returns exactly periods parts that add up to total. If periods < 1, raise ValueError("periods must be >= 1").
  • Compute base, remainder = divmod(total, periods). The first remainder parts are base + 1, and the rest are base, in that order.
  • Use integer arithmetic only, with no floats.
  • to_millis multiplies by 10 and returns Millis.
  • solve calls amortise(Cents(total), periods). It returns a tuple of two list[int], the cent parts and the same parts converted with to_millis. Let the ValueError from amortise propagate.
Total Periods Parts
100 3 [34, 33, 33]
-100 3 [-33, -33, -34]
1 3 [1, 0, 0]
-1 3 [0, 0, -1]
0 3 [0, 0, 0]

Negative totals need no special case. divmod rounds down, so divmod(-100, 3) is (-34, 2). The remainder is never negative when periods is positive, so two parts get -33 and one gets -34.

Build new lists to return

solve returns list[int], but amortise gives you a list[Cents], and mypy does not let you return one as the other:

Incompatible return value type (got "list[Cents]", expected "list[int]")  [return-value]

The reason is that a function that takes list[int] could append a plain int to your list of Cents. The exercise "Why a list[Dog] Is Not a list[Animal]", later in this track, explains this rule. For now, build a new list, such as [int(part) for part in parts].

Going further