Skip to content

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

Hard Framework

Bounds versus constraints: clamp and dedupe

One syntax, two entirely different mechanisms, and the wrong choice silently throws away subclass information from every return type in the module.

def f[T: int](x: T) -> T: ...          # BOUND       — T is int or any subtype
def g[T: (int, str)](x: T) -> T: ...   # CONSTRAINTS — T is exactly int, or exactly str

The colon-with-one-type is a bound: an upper limit. T solves to the most precise type the call site actually supplied, as long as it is a subtype of the bound. Pass a bool (an int subclass) and T is bool.

The colon-with-a-tuple is a constraint list. T must solve to exactly one of the listed types — the checker tries each in turn and collapses the result. Pass a bool and T becomes int, discarding the subclass. Pass a MyStr and T becomes str. Every downstream annotation loses the precision.

That is the trap: [T: (int, float)] looks like it means “int or float”, the way a union does, and it does not. A union is a type. Constraints are a substitution rule.

Constraints have one thing bounds cannot do: they reject mixed calls. clamp(1, 0.0, 2) cannot solve — T would have to be int and float simultaneously, and there is no single listed type that fits. A bound of int | float would happily accept it and produce nonsense.

The error message when you hit that, though, is famously unhelpful:

Value of type variable "T" of "clamp" cannot be "object"

object is the join the checker fell back to. It does not say “you mixed int and float”. Recognising that message is the lesson.

One deprecation worth knowing: AnyStr — the stdlib’s own constrained TypeVar — was deprecated in 3.13 with removal scheduled for 3.18, and the documented replacement is a PEP 695 constrained parameter, [A: (str, bytes)].

The task

def clamp[T: (int, float)](value: T, low: T, high: T) -> T: ...
def dedupe_sorted[T: Hashable](items: Iterable[T]) -> list[T]: ...

def solve(
    numbers: list[int],
    reals: list[float],
    low: int,
    high: int,
    words: list[str],
) -> tuple[list[int], list[float], list[str]]: ...

clamp — constraints. Returns value limited to [low, high], and raises ValueError("low must not exceed high") if the range is inverted. Because T is constrained, clamp(1, 0, 10) returns an int and clamp(1.0, 0.0, 10.0) returns a float, but clamp(1, 0.0, 10) does not type-check at all.

dedupe_sorted — a bound. Removes duplicates while preserving first-seen order, so an already-sorted input stays sorted. The Hashable bound is what lets you use a set for the seen-check; it is a bound rather than a constraint because you want the concrete element type to survive: dedupe_sorted of a list[MyStr] must reveal list[MyStr], not list[str]. Had you written [T: (str, bytes)] instead, it would reveal list[str] — and every caller would lose the subclass.

solve clamps numbers into [low, high], clamps reals into the same range converted to floats, and dedupes words.

Types

The float conversion in solve is not busywork: clamp(r, low, high) with r: float and low: int is precisely the mixed call the constraints exist to reject. You have to write float(low) and float(high), and being made to write it is the type system telling you a unit conversion is happening. That is the behaviour you want in code that mixes money, timestamps and ratios.

--disallow-any-generics also applies here: Iterable without a parameter is an error.