skip to content

Why accept Sequence[Animal] but return list[Animal] from a public function?

level: middleimportance: should knowfreq 48%

answer

  1. Inputs and outputs pull opposite ways
  2. Widening a parameter adds callers
  3. Narrowing a return costs the caller nothing
  4. Read-only abstract base classes are covariant
  5. Permissive in, precise out

basics

~20 s

Parameters should be the widest read-only type the body needs, so callers can pass a list, a tuple or a list of a subclass. Returns should be the concrete type you built, so callers keep its full API.

solid answer

~40 s

The two positions pull in opposite directions. A **parameter** is a place where a caller's value must fit yours, so choosing a covariant read-only type such as `collections.abc.Sequence[Animal]` or `Iterable[Animal]` maximises what is assignable: `list[Dog]`, `tuple[Dog, ...]` and any custom read-only view all satisfy it, while `list[Animal]` accepts nothing but an exactly-typed list. A **return value** is a place where your value must fit the caller's expectations, so being narrower is free — declaring `list[str]` gives callers `append`, slicing and mutation, whereas declaring `Sequence[str]` withholds an API you already handed them and forces a copy or a cast. The rule of thumb: be permissive in what you accept, precise in what you return. Declare a `Sequence` return only when the read-only-ness is a deliberate promise you intend to keep.

code

python · 13 lines
python
from collections.abc import Sequence

class Bid:
    def __init__(self, cents: int) -> None:
        self.cents = cents

class SponsoredBid(Bid): ...

def top_bids(bids: Sequence[Bid], keep: int) -> list[Bid]:
    return sorted(bids, key=lambda b: b.cents, reverse=True)[:keep]

batch: list[SponsoredBid] = [SponsoredBid(120), SponsoredBid(300)]
print([b.cents for b in top_bids(batch, 1)])

go deeper

for a junior

Remember the shape of a good signature: read-only abstract base classes such as Sequence on the way in, a concrete list or dict on the way out. Being able to state the rule and apply it is enough at this level.

for a middle

Explain why the two sides differ. Assignability runs toward your parameter and away from your return, so widening one and narrowing the other both increase what callers can do without any cast.

for a senior

Show where the rule stops. Name the cases that break it — a body that mutates, a body that iterates twice, a return that exposes internal state — and defend each deviation as a deliberate contract rather than an oversight.

for a principal

Own it as a house convention. Decide whether the codebase treats returned containers as owned by the caller, write the rule down with its exceptions, and weigh the review cost of enforcing it against the churn of casts it removes.

### Two positions, two opposite pressures A function signature has an input side and an output side, and variance affects them in mirror-image ways. On the **input** side, the caller supplies the value, so the question is *what can be assigned to my parameter type*. Every widening of the parameter adds callers. Because `collections.abc.Sequence` is declared covariant, `Sequence[Animal]` is satisfied by `list[Animal]`, `list[Dog]`, `tuple[Dog, ...]`, a `range`, a string of characters where appropriate, and any class implementing `__len__` and `__getitem__` as a read-only view. `list[Animal]` is satisfied by exactly one thing: a `list` whose declared element type is precisely `Animal`. That is the invariance of mutable containers, seen from the API author's chair. On the **output** side, your function supplies the value, so the question is *what can my return type be assigned to*. Here narrowness costs nothing and buys a lot. A declared `list[str]` return is automatically usable everywhere a `Sequence[str]` or an `Iterable[str]` is wanted, because the covariance runs in the caller's favour. Declaring the loose type instead throws away capability: a caller who receives a `Sequence[str]` cannot `append` to it, cannot `sort` it in place, and typically writes `list(result)` — an entirely pointless copy of an object your function had already built fresh. ### The guideline in practice Consider a module in an ad-auction bidder that scores a batch of bid records. ```python from collections.abc import Sequence def top_bids(bids: Sequence[Bid], keep: int) -> list[Bid]: return sorted(bids, key=lambda b: b.cents, reverse=True)[:keep] ``` The parameter is a `Sequence` because the body only iterates and indexes. A caller holding a `list[SponsoredBid]`, where `SponsoredBid` subclasses `Bid`, can pass it directly; had the parameter been `list[Bid]` that caller would have been stuck writing a cast or copying the list. The return is `list[Bid]` because `sorted` genuinely built a brand-new list that nobody else holds a reference to, so there is no reason to hide it behind a narrower interface. ### When the loose parameter is wrong The guideline is about *read-only* parameters, and it stops the moment the body writes. - If the function calls `append`, `sort` or item assignment, `Sequence` will not type-check — that is the abstract base class doing its job. Use `collections.abc.MutableSequence[Animal]`, or better, return a new list instead of mutating a caller's container. - If the function iterates the argument **twice**, do not annotate it `Iterable`. An `Iterable` may be a one-shot iterator, and the second pass will silently see nothing. `Sequence` is the honest choice when you need to re-read or need `len`. - If the function stores the argument for later use, think about who else can mutate it. `Sequence` says nothing about immutability, only about the operations *you* promise to use. ### When the tight return is wrong Returning a concrete `list` is the default, not a law. Return a narrower or protective type when you are handing back something you did not just build: - Returning an internal `list` attribute directly hands callers a live handle on your object's state. Returning a copy, or a read-only view declared as `Sequence`, is the safer contract — and there the loose annotation carries real information rather than losing it. - If you may later change the concrete type from a `list` to something else, a declared `Sequence` return keeps that freedom. Decide once, deliberately, and write it down; do not oscillate. ### The related mistakes Two failure modes show up repeatedly in review. The first is annotating a parameter `list[Animal]` out of habit and then, when a caller with a `list[Dog]` complains, reaching for `typing.Any` or a cast. That trades a compile-time conversation for a silent runtime hazard. The second is annotating everything — parameters *and* returns — as `Iterable`, which produces an API where every consumer starts by materialising the result and where re-iteration bugs are one refactor away. ### Version notes Use the abstract base classes from `collections.abc`, subscripted directly: `Sequence[Bid]`, `Iterable[Bid]`, `Mapping[str, Bid]`. This has worked in annotations since Python 3.9 (PEP 585), and the `typing.Sequence` and `typing.Iterable` aliases are deprecated in favour of it. The variance of these classes is unchanged on 3.14. ### A checklist for one signature Working through a single function, four questions settle every annotation on it. Does the body mutate the argument? If yes, the loose read-only type is unavailable and you should probably return a new container instead. Does the body traverse the argument more than once, or need `len`? If yes, `Sequence`, not `Iterable`. Did the function build the returned object itself? If yes, return the concrete type. Is the returned object internal state? If yes, return a copy, or declare the read-only type and mean it. None of these questions is about variance directly, but each one is decided by it.

  • When would you deliberately declare a Sequence return type instead of list?
    When the object being returned is not freshly built — typically an internal attribute. Declaring `Sequence` signals that callers must not mutate it and keeps you free to swap the concrete container later. It is a promise you are making on purpose, not a default; when the function built a new list, hand back the `list`.
  • Why is Iterable a riskier parameter type than Sequence?
    An `Iterable` may be a one-shot iterator such as a generator object. A body that loops over it twice sees an empty second pass and produces silently wrong results, with no exception. Annotate `Iterable` only when the body consumes the argument exactly once; use `Sequence` when you need `len`, indexing, or a second traversal.
  • The function needs to append to the argument. What now?
    `Sequence` will not type-check, correctly — it has no `append`. Either annotate `collections.abc.MutableSequence[Bid]`, which is invariant and so brings the original restriction back, or restructure the function to build and return a new list. The second is usually the better design: mutating a caller's container through a widened element type is the hazard variance exists to catch.

A good doorway is wide and a good gift is specific: accept anyone who fits through the frame, but hand over the actual object rather than a description of it.

saying these in an interview costs you the question

  • Annotates every parameter with a concrete list out of habit
  • Answers a caller's invariance error with a cast or Any
  • Returns Sequence everywhere, forcing callers to copy
  • Uses Iterable for a parameter the body iterates twice
  • Thinks Sequence guarantees the object is immutable
  • Cannot explain why the rule differs for inputs and outputs

context