Skip to content

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

Medium Primitives

Generic partition and chunk

A module-scope T = TypeVar("T") imported across five modules is a shared mutable definition dressed as a type. Change its variance or its bound in one place and the meaning of every signature that mentions it changes, silently, everywhere. There is no import graph you can look at to find out who was affected, because T looks like a boring constant.

PEP 695 (Python 3.12) makes the type parameter lexically scoped to the thing that declares it:

def partition[T](items: Iterable[T], predicate: Callable[[T], bool]) -> tuple[list[T], list[T]]:

The [T] is a real scope. Two functions each declaring [T] share nothing. At runtime the function gains a __type_params__ attribute, and — worth knowing because it surprises people — 'T' in locals() inside the body is False. The parameter is not a local variable; it lives in an implicit enclosing scope created just for the annotations.

Variance is inferred rather than declared, which is a separate topic, but the practical upshot for functions is: you almost never think about it.

The task

def partition[T](
    items: Iterable[T],
    predicate: Callable[[T], bool],
) -> tuple[list[T], list[T]]: ...

def chunk[T](items: Sequence[T], size: int) -> list[list[T]]: ...

def solve(
    items: list[int],
    threshold: int,
    size: int,
) -> tuple[list[int], list[int], list[list[int]], str]: ...

partition returns (matching, rest), each preserving input order. It takes Iterable[T] because it iterates exactly once — a generator, a set, a dict.items() view all work.

chunk splits a Sequence[T] into consecutive lists of at most size elements, the last one possibly short. It takes Sequence[T] rather than Iterable[T] because it slices and needs len. size < 1 raises ValueError with the message "size must be >= 1".

solve returns four things:

  1. the elements >= threshold, via partition;
  2. the rest;
  3. chunk(items, size);
  4. the message produced by calling chunk(items, 0) — the guard’s own error string, or "" if it did not fire.

The fourth element is there so the guard is actually tested rather than merely documented. Error messages are part of your API; a caller greps for them.

Pass iter(items) to partition in solve. It costs nothing and it proves the parameter really is an Iterable, not a list in disguise.

Why the parameter types differ

This is the choice you will be asked to defend in review, and the rule is short:

  • iterate once, no indexing, no len -> Iterable[T]
  • index or need len -> Sequence[T]
  • read a mapping -> Mapping[K, V]
  • mutate -> list[T], and its invariance is doing its job

Return list[T] and dict[K, V]. Accept broad, return narrow: the caller can always widen what you hand back, but cannot narrow what you demanded.

Types

The element type must survive the call. partition([1, 2, 3], f)[0] is list[int], not list[Any] — if it comes back as Any, the annotation is decorative and every downstream call site is unchecked. Similarly chunk(["a"], 1) is list[list[str]].

--disallow-any-generics will reject a bare list or Callable anywhere in your signatures, and Generic must not appear — the whole point of PEP 695 is that it is no longer needed.