skip to content

questions

3

Why does a type checker flag a Python method override that narrows a parameter type?

level: middleimportance: must knowfreq 48%

answer

  1. Callers only ever saw the base signature
  2. Inputs and outputs move in opposite directions
  3. Specialising the input is not specialising the class
  4. Accept at least as much, return at most as much
  5. Contravariant parameters, covariant return

basics

~20 s

A caller holding a base-typed reference may pass anything the base's signature accepts, and the subclass instance must handle it. So an override may widen a parameter type but never narrow it; return types go the other way and may only narrow.

solid answer

~50 s

Callers are written against the base's signature, so any value the base declared it accepts can arrive at the subclass instance. Narrowing a parameter breaks that promise: if the base takes `Sequence[str]` and the override takes `list[str]`, a caller passing a tuple through a base-typed name reaches code that expects list-only behaviour, and the checker rejects the override rather than wait for the crash. Parameters are therefore **contravariant** — an override may accept the same type or a wider one — while the return type is **covariant** and may only be the same or narrower, because the caller was promised at least the base's return. The same reasoning covers arity and names: an override may not add a required parameter, may not rename one that callers pass by keyword, and may add parameters only with defaults. `*args`/`**kwargs` typed permissively accept everything and are the universal escape hatch.

code

python · 20 lines
python
from collections.abc import Sequence

class Geocoder:
    def lookup(self, keys: Sequence[str]) -> object:
        return list(keys)

class ListGeocoder(Geocoder):
    # Checker error: parameter narrowed from Sequence[str] to list[str]
    def lookup(self, keys: list[str]) -> object:
        keys.sort()
        return keys

def run(g: Geocoder) -> object:
    return g.lookup(("b", "a"))

# AttributeError at run time: 'tuple' object has no attribute 'sort'
try:
    run(ListGeocoder())
except AttributeError as exc:
    print(exc)

go deeper

for a junior

Learn the shorthand and one example: an override may accept a broader parameter type and must not accept a narrower one, and it may return a more specific type but never a broader one. Know that Python itself does not enforce this.

for a middle

Explain the mechanism, not just the rule: the caller was checked against the base signature, so everything the base advertised must still work. Cover arity, parameter names for keyword calls, defaults, and why the checker blames the override rather than the call.

for a senior

Show what you do when a subclass genuinely cannot accept everything: validate at run time with a clear error, make the base generic in that parameter, or drop the inheritance and compose. Be able to describe how the untreated version fails in production.

for a principal

Own the design consequence. Decide when a hierarchy whose subclasses keep wanting narrower inputs should stop being a hierarchy, and set the policy on escape hatches such as permissive *args/**kwargs overrides that silence the checker wholesale.

## The promise being kept When a checker sees a call, it uses the **declared** type of the receiver, not its run-time class. Code written as `def run(g: Geocoder) -> None: g.lookup(...)` is checked against `Geocoder.lookup` and will happily be handed a `CachingGeocoder` at run time. Every rule about override signatures follows mechanically from that one fact: whatever `Geocoder.lookup` promised its callers, every subclass implementation has to deliver, because callers were never told which implementation they would reach. That splits into two directions. **Parameters are contravariant — an override may widen them, never narrow them.** The base's signature is an advertisement of what callers may pass. If the base accepts `Sequence[str]`, callers are entitled to pass a tuple, a list, or anything else that satisfies it. An override declaring `list[str]` has quietly retracted part of that advertisement while the advertisement is still posted. So the checker rejects the override, not the call site — the call site was written correctly against the base. **The return type is covariant — an override may narrow it, never widen it.** The base's return annotation is a promise about what callers get back. Returning something more specific keeps the promise (every `list[str]` is a `Sequence[str]`); returning something broader breaks it, because a caller that was promised a sequence and handed an `object` has code that no longer type-checks. Narrowing a parameter is the far more common bug because it feels like specialisation. "This subclass is the list-based geocoder, so of course its `lookup` takes a list." But specialising the *input* is not what subtyping means. Specialising the input means the subclass is usable in strictly fewer places than the base, which is the definition of not being substitutable for it. ## What else the checker compares Signature compatibility is more than the type of each parameter: - **Arity.** An override may not add a required parameter, because base-typed callers do not pass it. Adding a parameter *with a default* is fine — every base-legal call still works. - **Names.** Parameters that callers may pass by keyword are part of the contract, so renaming one in an override is an error even when the types line up. Marking a parameter positional-only in the base with `/` removes its name from the contract and frees subclasses to rename it. - **Kinds.** Turning a keyword-capable parameter into a positional-only one narrows what callers may do and is rejected; the reverse widens and is accepted. - **Defaults.** Removing a default makes a previously optional parameter required, which is the arity problem again. - **The escape hatch.** An override written `def lookup(self, *args: object, **kwargs: object) -> str` accepts every possible call and is always compatible with the base signature — at the price of giving up all checking inside the body. None of this depends on `typing.override`. These rules apply to every method that shadows a base member; the decorator adds an orthogonal check that the base member exists at all. They are also not run-time rules: Python will happily call an incompatible override and raise `TypeError` only if the actual arguments do not fit, or — worse — not raise at all and produce wrong results. ## When you genuinely need a narrower input If a subclass truly cannot handle everything the base accepts, the type system is telling you something real, and there are three honest answers. Keep the base's parameter type and validate at run time, raising a clear error for the inputs you reject — this is at least explicit, though it converts a static guarantee into a run-time one. Make the base generic in that parameter's type so each subclass supplies its own, which keeps substitutability intact for each concrete parameterisation. Or accept that the class is not a subtype: hold the base object as a member instead of inheriting from it, and expose whatever narrower method you actually want. The fourth option — silence the checker and hope — is the one that produces the bug the rule was written to prevent, and it typically surfaces in the least convenient place: a batch job that happily processes ten thousand records through the base path and then fails on the one subclass-specific call at the end, after the intermediate results have been discarded. ## Interview shorthand "Accept at least as much, return at most as much." Parameters widen, returns narrow, arity may only grow with defaults, and keyword-visible names are frozen unless the base made them positional-only.

  • May an override add a parameter the base method does not have?
    Only if it has a default, or is collected by `**kwargs`. Callers holding a base-typed reference never pass it, so a new *required* parameter would make base-legal calls fail. A new optional parameter leaves every existing call valid and is accepted.
  • Why is renaming a parameter in an override an error even when the types match?
    Because a caller may pass that parameter by keyword through the base signature, so the name is part of the contract. If the base declares it positional-only with `/`, the name is not part of the contract and a subclass may rename it freely.
  • Is widening the return type ever acceptable?
    No. The caller was promised at least the base's return type and may immediately use it — call `.upper()` on a promised `str`, index a promised `Sequence`. Returning something broader invalidates code that already type-checked. Narrowing is always fine, which is why factory-style methods commonly return a more specific type in subclasses.

A subclass is a substitute teacher. The class was told the lesson covers any question about the syllabus, so the substitute must field all of them; answering only questions about one chapter breaks the arrangement, even though giving a more detailed answer than promised does not.

saying these in an interview costs you the question

  • Says a subclass may specialise its parameters because it is more specific
  • Treats parameters and return types as varying the same way
  • Thinks Python raises at class creation for an incompatible override
  • Believes adding a required parameter is fine with a keyword-only marker
  • Assumes the rules only apply when the override marker is present
  • Claims renaming a parameter is always harmless

context

open as a page

What does the @override decorator from Python's typing module tell a static type checker?

level: juniorimportance: should knowfreq 38%

basics

~20 s

typing.override, added in Python 3.12 by PEP 698, marks a method as deliberately replacing one inherited from a base class. A checker reports an error when no base class declares that name. At run time the decorator does nothing.

open as a page

Why does a type checker reject a subclass that redeclares an inherited attribute with a narrower type?

level: seniorimportance: should knowfreq 28%

basics

~20 s

A plain attribute is both read and written through a base-typed reference, so its declared type is invariant: base code may assign anything the base's annotation allows, which a narrower subclass declaration cannot honour. Only read-only positions may narrow.

open as a page