Why does adding `| None` to a function's return annotation break callers when adding it to a parameter does not?
answer
- Ask who relies on the promise
- Inputs and outputs are not symmetric
- One side must now handle None
- Runtime unchanged, contract weakened
basics
~20 sWidening a parameter accepts everything callers already passed, so it stays compatible. Widening a return hands callers a value they were never told to handle: every use of the result must now rule None out before touching it.
solid answer
~50 sA signature promises two things: what the function accepts and what it produces. Adding `| None` to a **parameter** only makes the accept-promise more generous, so every existing call remains valid. Adding `| None` to a **return** weakens the produce-promise, and every caller that used the result as a `str` is now wrong: `load(shard).encode()` becomes a type error at the *call site*, or an `AttributeError` on `None` in production where the consumer is not checked. The trap is that nothing at runtime changes, so tests stay green — a search-index rebuilder whose loader starts returning `None` on an encoding mismatch instead of raising will sail through a 27-minute suite, because no existing test reaches the new branch. Treat a return-union widening as a contract change: decide whether raising is the better answer, then run the checker over the whole tree and fix the call sites in the same change.
code
python · 8 linesdef load(shard: str) -> str | None:
return {"docs": "payload"}.get(shard)
value = load("docs")
if value is not None:
print(value.encode("utf-8"))
print(load("missing"))go deeper
Recall that a value typed str | None cannot be used as a str until the None case is handled - guard it before calling string methods on it, rather than assuming the happy path.
Explain why a checker reports the error at the call site rather than in the function you edited, and why a test suite that never reaches the new None branch stays green through the change.
Demonstrate treating a return union as a contract change: enumerate and fix call sites in the same commit, and argue concretely when raising beats returning None for a given failure.
Own the policy across boundaries: which layers may return nullable results, how such a change is rolled out to consumers you cannot edit in one commit, and when to add a second entry point rather than weaken an existing contract.
### The asymmetry, stated precisely A signature is a two-sided promise. The parameter annotation says what the function promises to **accept**; the return annotation says what it promises to **produce**. Adding a member to a union widens the set of values on that side: - Widening a **parameter** — `str` becomes `str | None` — only strengthens the promise to accept. Every call that was legal before is still legal, because every argument callers already pass is still a member of the wider set. The change is compatible. - Widening a **return** — `str` becomes `str | None` — weakens the promise to produce. Callers built code on the guarantee that a `str` came back; that guarantee is gone, and every use of the result is now underspecified. The mirror image holds too: narrowing a return (`str | None` to `str`) is safe for callers, while narrowing a parameter is the breaking direction. Inputs are safe to widen, outputs are safe to narrow. ### What the callers actually see Given `def load(shard: str) -> str:`, a call site writes `load("docs").encode("utf-8")` with no ceremony. Change the annotation to `-> str | None` and that same line becomes a type error at the *call site*, not in the function you edited: a value of type `str | None` has no guaranteed `encode`. In a fully checked project this is exactly what you want — the tool enumerates the work. In a project that is only partly checked, or where the changed module is checked and its consumers are not, the error surfaces instead as `AttributeError: 'NoneType' object has no attribute 'encode'` in production. ### Why the test suite does not save you Take a search-index rebuilder whose loader gains a `None` return on an encoding mismatch, where before it raised. Nothing about the existing paths changed, so the 27-minute suite goes green on the first run: no existing test produces a document that trips the new branch, because the branch is new. The change is invisible to every runtime signal you have and visible only to the checker — which is the whole argument for treating a return-union widening as a contract change rather than a refactor. ### How to land one safely 1. **Decide whether `None` is the right answer at all.** Returning `None` for a failure trades a loud, typed exception for a quiet value that must be threaded through the caller. Absence is a good return value when it is an ordinary outcome the caller routinely handles — a cache miss, a key that is not present, a configuration value that is unset. A genuine fault, such as a document that cannot be decoded, is usually better as an exception: it carries a message, a type and a traceback, and it cannot be accidentally ignored. 2. **Change the call sites in the same change.** Run the checker across the whole tree, not just the edited file, and fix what it reports. A union widening with no call-site churn in the same commit is almost always an unfinished change. 3. **If the consumers are not yours to edit, add rather than mutate.** Keep the existing function's contract and introduce a second entry point with the nullable result, the way a mapping offers both an indexing operation that raises and a `get` that returns `None`. That pair is the standard shape precisely because it lets each caller choose which contract it wants. 4. **Do not let one `None` mean two things.** If the result can be absent for two distinct reasons, `T | None` cannot say which, and callers will guess. Either raise for one of them or return something that distinguishes them. ### The reverse trap on the parameter side Because widening a parameter is safe, engineers sometimes conclude that adding `| None` to a parameter is free. It is compatible for callers, but it is not free for the body: it commits the function to handling `None` on every path, and if the body simply substitutes a default in the first line, a real default value in the signature would have been the smaller change. ### Where the boundary matters most The asymmetry gets sharper as the distance to the caller grows. Inside one module, widening a return is a mechanical fix. Across a package boundary, it is an API change that belongs in release notes. Across a service or an interface other teams implement, the return union has to be handled by consumers you cannot see and cannot fix in the same commit, which is where the "add a new function rather than widen the old one" rule stops being fussy and starts being the only workable option. ### What an interviewer is listening for That you name the direction of the asymmetry without hedging; that you notice runtime behaviour and tests will not flag it; that you enumerate call sites and fix them in the same change; and that you can argue when `None` is a legitimate result versus a swallowed exception.
- Which direction is safe on a parameter: widening or narrowing?Widening. Every argument callers already pass is still a member of the wider set, so no call site breaks. Narrowing a parameter - `str | None` down to `str` - is the breaking direction, because callers that legitimately passed None are now wrong. Returns are the mirror image: narrowing a return is safe for callers, widening it is not.
- Your loader hits a document it cannot decode. Return None or raise?Raise. An undecodable document is a fault, not an ordinary outcome: an exception carries a type, a message and a traceback, and it cannot be ignored by accident. Return None when absence is a normal result callers routinely handle - a cache miss, a key that is not present, an unset configuration value. The test is whether the caller has a sensible ordinary path for the empty case.
- How do you find every call site a widened return affects?Run the project's type checker across the whole tree in the same change, not just the edited file, and treat its report as the work list; a union widening that produces no call-site churn is usually an unfinished change. Grep supplements it for consumers outside the checked tree, and for anything you cannot edit, add a new entry point instead of changing the existing contract.
Widening a parameter is a doorway that now admits a wider load; widening a return is promising a parcel and sometimes delivering an empty box, so everyone downstream has to open it before they can rely on it.
saying these in an interview costs you the question
- Calls a return-type change safe because runtime is unchanged
- Expects the existing test suite to catch the new None branch
- Thinks widening a parameter to a union breaks callers
- Returns None for faults to avoid the cost of exceptions
- Treats an annotation change as documentation only