Skip to content

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

Medium Primitives

Accept broad, return narrow: group_by and merge_counts

“Take Iterable here, not list“ is the most common concrete piece of feedback in a review of an otherwise-fine function. It is worth internalising the rule rather than collecting the comment.

Accept broad:

you do this to the argument annotate it
iterate it once Iterable[T]
index it, slice it, or need len Sequence[T]
look keys up, read values Mapping[K, V]
mutate it list[T] / dict[K, V] — and invariance is correct

Return narrow: hand back list[T] and dict[K, V]. The caller can widen what you give them to whatever they like; they cannot narrow what you demanded. A function returning Iterable[str] forces every caller who needs an index to call list() on it, and forces every reviewer to ask whether it is single-use.

The payoff is concrete: a list, a tuple, a set, a generator expression, a dict.items() view and a MappingProxyType are all valid arguments to a function annotated broadly, and none of them are valid to one annotated list[...].

The task

def group_by[T, K](items: Iterable[T], key: Callable[[T], K]) -> dict[K, list[T]]: ...
def merge_counts(sources: Iterable[Mapping[str, int]]) -> dict[str, int]: ...

def solve(
    words: list[str],
    counts: list[dict[str, int]],
    trap: str,
) -> tuple[list[str], list[list[str]], dict[str, int], list[str]]: ...

group_by returns a dict from key to the list of items with that key. Insertion order is preserved for both the groups (first-seen key order) and the members within a group.

merge_counts sums values across sources, per key. It takes Iterable[Mapping[str, int]] — note both levels are broad. A module constant

HOUSE_COUNTS: Final[Mapping[str, int]] = MappingProxyType({"z": 1})

is merged in on every call, and MappingProxyType is not a dict. If you annotate the parameter Iterable[dict[str, int]], that call fails; if you annotate it list[...], the [*counts, HOUSE_COUNTS] splat fails too.

solve returns four things:

  1. the group keys, in first-seen order — grouping words by first character (word[:1], so the empty string groups under "");
  2. the corresponding group members;
  3. merge_counts([*counts, HOUSE_COUNTS]);
  4. the distinct characters of trap, in first-seen order.

The trap, taught explicitly

Element 4 exists because of this:

group_by(trap, lambda char: char)

trap is a str. A str is a Sequence[str], so this type-checks perfectly, T solves to str, and the function groups characters. There is no error, no warning, and no way for the checker to know you meant to pass a list of words.

This is why process("abc") where process(items: Iterable[str]) silently iterates three characters instead of failing. It is one of the very few places where accepting the broad type costs you something, and the mitigation is knowing it exists — plus, if the distinction matters, taking Sequence[str] | None and rejecting str explicitly, or naming the parameter words so the call site reads wrong.

Types

group_by needs two type parameters, [T, K], and both must survive: grouping a list[str] by a Callable[[str], str] yields dict[str, list[str]], not dict[Any, list[Any]].

--disallow-any-generics rejects a bare Callable, Mapping or dict anywhere in your signatures.