Skip to content

← Interfaces with Protocols and ABCs step 13 of 26

Hard Primitives

Type a Lookup with a Default Using Overloads

fetch(store, key) looks up a key in a mapping of strings, and callers can pass a default for a missing key. What comes back depends on the call:

  • fetch(store, key) gives a str, or None when the key is missing.
  • fetch(store, key, 0) gives a str, or the int default.
  • fetch(store, key, "?") always gives a str.

The starter has one signature that returns object. It fits every call but tells callers nothing. Submit it, and mypy rejects the code that uses the results:

L16:53 [attr-defined]
"object" has no attribute "upper"

L20:24 [attr-defined]
"object" has no attribute "lower"

One function, several signatures

An overload is one of several signatures for one function, each marked @overload, so the return type can depend on the arguments. Write the overloads first, with ... as their bodies:

@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: ...

Then write one ordinary implementation, with no decorator, that handles every call at runtime. mypy checks each call against the overloads in order and uses the first one that matches. T is the type of the default, as in the generic functions of the type hints track. A "?" default makes the result str | str, which is the same as str.

mypy checks the body of the implementation only against its own signature. So its parameters must accept both call forms, and its return type must cover the result of every overload.

What to write

Import overload from typing. Copy the two overloads above exactly, and add one implementation after them. It returns:

  • The stored string when the key exists, even an empty string.
  • The default object itself when the key is missing.
  • None when the key is missing and the caller passed no default.

Do not change the mapping or the default. The store holds only strings, so store.get(key) returns None only for a missing key. Do not write value or default, because it treats a stored "" as missing. mypy accepts that bug, and a hidden test catches it.

Your code must pass mypy --strict without # type: ignore or cast.

Check the result types

assert_type from typing does nothing at runtime, and mypy reports an error when the type it infers differs from the one you wrote. With your overloads, mypy accepts all four lines:

from typing import assert_type

values: dict[str, str] = {"name": "Ada"}
assert_type(fetch(values, "name"), str | None)
assert_type(fetch(values, "name", 0), str | int)
assert_type(fetch(values, "name", "?"), str)
assert_type(fetch(values, "name", None), str | None)

The last line shows that a None default keeps None in the result.

What the driver returns

Keep solve as it is. For each key, in order, it appends three strings, one for each call form. The first is "MISS" or the stored string in upper case. The second is the repr of the lookup with the integer fallback. The third is the lookup with a "?" default, in lower case. For store={"a": "X"}, keys=["a", "missing"] and fallback=7, it returns ["X", "'X'", "x", "MISS", "7", "?"].

The starter already returns the right strings, so mypy is what this exercise grades. See function overloading in the mypy documentation.