Skip to content

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

Hard Framework

Limit a Type Parameter with Bounds and Constraints

Write two generic helpers. clamp keeps a number inside a range, and dedupe_sorted removes repeated words. A plain [T] accepts any type, but clamp must compare its arguments and dedupe_sorted must put items in a set. So each helper limits what T can be, in a different way.

clamp(-3, 0, 10)                     # 0
clamp(11.5, 0.0, 10.0)               # 10.0
dedupe_sorted(["c", "b", "c", "a"])  # ["c", "b", "a"]

Two ways to limit a type parameter

A bound says that T can be this type or any subtype of it. A subtype can go anywhere the wider type is expected, as a bool can go anywhere an int can. With a bound, mypy keeps the exact type the caller passed:

def keep_int[T: int](value: T) -> T:
    return value

reveal_type(keep_int(True))  # bool, a subtype of int

A constraint says that T must be exactly one of the listed types. mypy picks one of them, even when the caller passes a subtype:

def keep_one[T: (int, str)](value: T) -> T:
    return value

reveal_type(keep_one(True))  # int, not bool

dedupe_sorted uses the bound Hashable, so a list of a str subclass comes back as a list of that subclass. clamp uses the constraint (int, float), so all three arguments share one of those two types.

mypy lets you pass an int where a float is expected. So clamp(1, 0.0, 10) passes mypy, with T as float. Python converts nothing at runtime, though, and that call returns the int 1. That is why solve converts its endpoints to float itself.

What to write

from collections.abc import Hashable, Iterable

def clamp[T: (int, float)](value: T, low: T, high: T) -> T:
    ...

def dedupe_sorted[T: Hashable](items: Iterable[T]) -> list[T]:
    ...

clamp:

  • If low > high, raise ValueError("low must not exceed high"). Check this first, before you compare value.
  • Return low when value is below low, and high when value is above high. Otherwise return value unchanged.
  • Both ends are inclusive, so clamp(10, 0, 10) returns 10. When low equals high, every value becomes that number.

dedupe_sorted:

  • Keep the first occurrence of each item, in the order the items arrive, and do not sort. Sorted input stays sorted, and unsorted input keeps its order.
  • Read items once. Use a set to remember the items you have seen.
  • Return the original item objects, and leave the input unchanged.

What solve returns

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

Return a tuple of three lists, each in input order:

  1. Each of numbers, clamped to low and high.
  2. Each of reals, clamped to float(low) and float(high). The conversion makes an endpoint come back as a float, such as 10.0, not 10.
  3. dedupe_sorted(words).
  • Let a ValueError from clamp propagate.
  • If numbers and reals are both empty, clamp never runs. Return the two empty lists and the deduplicated words, even when low > high.
solve([-2, 7], [-0.5, 12.0], 0, 10, ["b", "a", "b"])
# ([0, 7], [0.0, 10.0], ["b", "a"])

Going further

A constraint also rejects calls that mix the listed types. With def concat[S: (str, bytes)](a: S, b: S) -> S, mypy rejects concat("a", b"b"), because no single listed type fits both arguments. The typing specification covers type parameters and the rule for int and float.