Skip to content

← Structural Typing and the Hard Parts step 10 of 24

Hard Primitives

@overload: one implementation, several honest signatures

dict.get has two behaviours: without a default it can return None, with one it cannot. One signature cannot say that. Widening the return to str | None forces every caller who did pass a default to check for a None that can never arrive; widening to object destroys the information entirely. @overload is how you describe both behaviours with one runtime function.

The rules

  • At least two @overload variants are required.
  • Variant bodies are empty (...). They are declarations, not code.
  • The implementation follows, undecorated, and is never checked against a call site. Callers are resolved against the variants only. That is the sharp edge: the place where the runtime actually lives is the one place the checker is not validating your claims.
  • The implementation’s signature must be compatible with every variant. In practice its return type is the union of all the variant returns — a supertype, so it can produce each of them.

Resolution follows the typing spec’s six steps: match on arity, then on actual argument types, then argument-type expansion, then discard non-variadic candidates under indeterminate arguments, then check all materializations, then take the first still standing. Order matters, and putting a broader variant first makes the narrower ones unreachable — overload-cannot-match.

Two anti-patterns worth naming: overloads that differ only in return type are unsatisfiable (the checker cannot pick between them from the arguments), and overloads used where a single type variable would do. The spec’s own guidance is to reach for @overload only when a type variable is insufficient.

Your task

Give fetch two variants over one implementation:

@overload
def fetch(store: Mapping[str, str], key: str) -> str | None: ...
@overload
def fetch[T](store: Mapping[str, str], key: str, default: T) -> str | T: ...

The implementation returns the stored value if the key is present, otherwise the default (None when none was supplied).

def solve(store: dict[str, str], keys: list[str], fallback: int) -> list[str]:

For each key, append three entries in this order:

  1. fetch(store, key)"MISS" if it is None, else .upper()
  2. repr(fetch(store, key, fallback)) — here T binds to int, so the type is str | int
  3. fetch(store, key, "?").lower() — here T binds to str, the union str | str collapses to str, and .lower() needs no narrowing at all

Those three lines are the assertion: each one only compiles if the variant it resolves to has the return type the overloads promise.

Method decorators

When overloading a method, @staticmethod / @classmethod goes on every variant and the implementation. @final and @override, by contrast, go on the implementation only.

Loading visualization…