skip to content

When is @typing.overload better than annotating one union return type?

level: middleimportance: must knowfreq 45%

answer

  1. The return depends on what came in
  2. Callers should not narrow what you know
  3. Some argument combinations should be illegal
  4. A Literal flag keys the shape
  5. Prefer a type variable when it fits

basics

~20 s

Use @typing.overload when the return type depends on which arguments were passed. A single union return makes every caller narrow it; overloads give each call site the one exact type, and can make invalid argument combinations a checker error.

solid answer

~50 s

A union return is honest but lossy: `def read(key: str, *, text: bool) -> str | bytes` tells the caller that either could come back, so every call site has to `isinstance`-narrow a result the author already knows. Overloads restore the correlation — one stub says `text: Literal[True] -> str`, another says `text: Literal[False] -> bytes`, and each call site gets a single concrete type. The second thing they buy is *forbidding* combinations: if no stub accepts `key=None` together with `strict=True`, that call is an error rather than a runtime surprise. The price is duplication and worse diagnostics — a mismatch reports "no overload variant matches" instead of pointing at one parameter — so reach for them when the return genuinely varies, and prefer a type variable when the return is simply the argument's own type, or two named functions when the shapes have nothing in common.

code

python · 12 lines
python
from typing import Literal, overload

@overload
def read_row(key: str, *, text: Literal[True]) -> str: ...
@overload
def read_row(key: str, *, text: Literal[False]) -> bytes: ...
def read_row(key: str, *, text: bool) -> str | bytes:
    raw = key.encode("utf-8")
    return raw.decode("utf-8") if text else raw

print(read_row("row-2.4", text=True))
print(read_row("row-2.4", text=False))

go deeper

for a junior

Know what the decorator is for: several declared shapes of one function, so the caller gets a precise return type. You mostly read these rather than write them, so recognise the stub-then-implementation pattern.

for a middle

Explain the mechanics: a union return forces caller-side narrowing, overloads correlate arguments with the return, and the implementation must accept everything the stubs promise. Be able to write a Literal-keyed pair from memory.

for a senior

Argue both directions. Show when you refuse overloads — combinatorial growth, unreadable diagnostics, a type variable that says it in one line — and show that you still validate at runtime because the checker's promise stops at code it saw.

for a principal

Own the API-design tradeoff: whether a flag with two return types should exist at all versus two clearly named functions, and what the duplication costs a team over time in drift, review load and error-message quality.

## The problem overloads solve Annotations describe one signature. When a function's return type depends on *which* arguments came in, a single signature can only tell the truth by widening: it returns a union, and it tells every caller the same thing regardless of what that caller passed. ```python def read_row(key: str, *, text: bool) -> str | bytes: ... value = read_row("row-1", text=True) # checker says: str | bytes value.upper() # error: bytes has no upper ``` The author knows `text=True` means `str`. The annotation cannot say so, so every call site pays with a narrowing dance — an `isinstance` check, an `assert`, or a cast — for information that was available at the call. `typing.overload` restores that correlation. Each decorated stub is one *shape* of the callable, and a checker resolves a call against the stubs rather than against the implementation: ```python from typing import Literal, overload @overload def read_row(key: str, *, text: Literal[True]) -> str: ... @overload def read_row(key: str, *, text: Literal[False]) -> bytes: ... def read_row(key: str, *, text: bool) -> str | bytes: raw = key.encode("utf-8") return raw.decode("utf-8") if text else raw ``` Now `read_row("row-1", text=True)` is a `str` at the call site, with no narrowing and no cast. ## The second thing overloads buy: forbidding combinations A union return is about the output; overloads also constrain the *input space*. If a callable accepts `(source: str)` or `(source: bytes, *, encoding: str)` but never `(source: str, *, encoding: str)`, two stubs express exactly that, and the illegal third combination fails to match any of them. A single signature would have to make `encoding` optional for both and hope a comment is read. This is the everyday reason libraries use overloads for flag-shaped parameters, mode strings, and functions whose arity changes meaning — a `Literal` flag keys the shape, and combinations nobody implemented simply do not type-check. ## The contract with the implementation The single undecorated implementation is invisible to callers, but it is not unconstrained. Its parameters must be broad enough to accept everything any stub accepts (typically the union of the stub parameter types, or a plain `bool` where the stubs used `Literal[True]`/`Literal[False]`), and its return type must be compatible with each stub's declared return. A checker verifies that relationship, which is what stops the stubs from promising something the body cannot deliver — though only structurally: it checks that the body *can* return `str`, not that it returns `str` exactly when `text` is true. ## When not to use them Three cases where overloads are the wrong tool: - **A type variable already expresses the relationship.** If the return is just the element type of the argument, `def first[T](values: list[T]) -> T` says it in one signature. Since Python 3.12 that PEP 695 syntax needs no separate `TypeVar` declaration. Overloads here are pure duplication. - **The shapes have nothing in common.** Two unrelated behaviours behind one name is usually two functions with two names, which reads better and produces better errors. - **The combinatorics explode.** Overloads multiply: three flag parameters is eight stubs, kept in sync by hand. Past a handful, take the union return and let callers narrow, or split the API. The diagnostics cost is real too. When an argument is wrong, a checker cannot say "this parameter is wrong" — it says the call matched no variant and often lists them all. Long overload sets make error messages long. ## And the standing caveat Everything above happens in the checker. Overloads dispatch nothing and validate nothing at runtime; the interpreter sees one function whose body must still handle every case it was declared to accept. They buy call-site precision and design documentation, not enforcement. ## How to answer "When the return type is a function of the arguments, or when some argument combinations should be illegal. A union return makes every caller narrow something I already know; overloads move that knowledge into the signature. I pay for it with duplication, so I only do it where the correlation is real, and I reach for a type variable first when the return is simply the argument's own type."

  • What signature should the single implementation carry when the stubs disagree?
    One broad enough to accept every call any stub accepts, with a return compatible with all of them — typically plain `bool` where the stubs used `Literal[True]` and `Literal[False]`, returning the union. A checker verifies that relationship, and callers never see it: resolution happens against the stubs only.
  • What is the cost of overloads on a widely used public API?
    Duplication that drifts, combinatorial growth when several parameters key the shape, and much worse diagnostics — a bad call reports that no variant matched instead of naming the offending parameter. Past a handful of stubs, a union return plus caller-side narrowing, or two separately named functions, is usually the better design.
  • Do overloads make anything safer at runtime?
    No. They are erased as far as execution is concerned: one function object exists and its body must still handle every case. What they buy is call-site precision and a machine-checked statement of intent in CI, so mistakes surface before deploy rather than during it.

saying these in an interview costs you the question

  • Claims overloads make dispatch or execution faster
  • Thinks callers see the implementation signature
  • Says a union return and overloads are equivalent to callers
  • Adds a stub for every parameter combination reflexively
  • Uses overloads where a type variable expresses the relation
  • Believes overloads reject bad arguments at runtime

context