The Robustness Principle and Type Annotations | Hamilton Kibbe
This is one of those things that seems obvious, but in my experience is frequently overlooked. I’ve had versions of this conversation with several coworkers over the years, so I figured it was worth writing down.<br>The idea is that when annotating functions and methods, parameters should accept the widest type that the function or method will work correctly with, and return values should be as concrete as possible. This probably sounds familiar as it is effectively the Robustness Principle (aka Postel’s Law):
Be conservative in what you send, be liberal in what you accept.
While some may argue that Postel’s Law is harmful for wire protocols, I don’t think the same failure mode applies here: we aren’t accepting invalid or non-conforming input. In fact, the type checker will prevent exactly that.
A Simple Example
Here’s a perfectly reasonable-looking function:
Copy
def calculate_total_cost(items: list[Item]) -> Decimal:<br>return sum((item.cost for item in items), start=Decimal(0))
This looks fine until your caller ends up with something like this
Copy
in_stock = (item for item in inventory if item.quantity > 0)<br>total_cost = calculate_total_cost(list(in_stock))
The annotation forces the caller to materialize it into a list, only to iterate over the items one at a time anyway. The type annotation in this case is just adding useless overhead.<br>While the workaround in this case is mostly just annoying, sometimes it is expensive:
Copy
def get_items_in_stock() -> Iterator[Item]:<br>with open("inventory.txt") as f:<br>for line in f:<br>yield Item.from_str(line)
in_stock = get_items_in_stock()
# The whole file ends up in memory just to satisfy the annotation<br>total_cost = calculate_total_cost(list(in_stock))
Our implementation doesn’t actually care that items is a list of Items, just that it can iterate over items and get Items from it.<br>The Python standard library provides a whole suite of generic collection types in the collections.abc module.
Rewriting the function using this principle gets us something very similar
Copy
def calculate_total_cost(items: Iterable[Item]) -> Decimal:<br>return sum((item.cost for item in items), start=Decimal(0))
The only difference is that the annotation for items now describes the capabilities that the implementation requires, not a specific type.<br>In this example, the implementation only needs to iterate over the input, so we can use an annotation that allows anything that supports iteration. If we need the length of the collection as well, Collection[Item] would be a better choice, as in this example:
Copy
def calculate_mean_cost(items: Collection[Item]) -> Decimal:<br># Ignore div-by-zero for brevity<br>return sum((item.cost for item in items), start=Decimal(0)) / len(items)
Return Types
The other half of the principle is returning concrete types whenever practical.
Consider the following function:
Copy
def get_active_users() -> Iterable[User]:<br>...
As a caller, what exactly am I getting back? A list? A set? A generator? Can I iterate over it multiple times? Can I sort it?<br>The type annotation doesn’t tell me.
Returning a concrete type provides stronger guarantees:
Copy
def get_active_users() -> list[User]:<br>...
Now callers know exactly what operations are available without having to inspect the implementation. The annotation communicates not only what values are returned, but also the capabilities of the returned object.1
Some Exceptions
A concrete return type is a commitment, and there are times when that may not be what you want. Here are a few examples that come to mind:
Public APIs. Once you publish a function that returns list[User], list is part of your contract. If a future version wants to return a tuple, or stream results lazily, that’s a breaking change. Generally not an issue within your own codebase, since you own the callers, but an easy way to box yourself in as a library maintainer.
Lazy APIs. If a function streams results, Iterator[Item] is the concrete truth. It tells callers everything they need to know — one pass, no len(), consumed when you’re done.
Handing out internal state. This one is sneakier. Consider a class that returns a reference to its own data:
Copy
class UserRegistry:<br>def __init__(self) -> None:<br>self._active: list[User] = []
def get_active_users(self) -> list[User]:<br>return self._active
The annotation for the return type of get_active_users says list, and it would be reasonable for callers to treat it like their own:
Copy
users = registry.get_active_users()<br>...<br>users.clear() # surprise: the registry just lost all of its users
If you’re returning a fresh list the caller owns, list[User] is the right annotation. If you’re handing out a reference to something you keep, you can annotate it Sequence[User] instead. Sequence has no append or clear, so the type checker will stop callers from mutating...