skip to content

In Python type hints, why does hard-coding the class name as a fluent method's return type break subclasses?

level: middleimportance: must knowfreq 45%

answer

  1. The checker believes the annotation, not the code
  2. Run time is fine; the contract is wrong
  3. The chain widens back to the base class
  4. Subclass methods vanish after the first inherited link

basics

~20 s

A checker takes the annotation literally, so the method is treated as returning the base class and the subclass's own methods disappear from the rest of the chain. typing.Self instead resolves to whichever class the call was made on.

solid answer

~40 s

The annotation is a promise and the checker believes it exactly. If `Rule.tagged` is declared `-> Rule`, then `ScoredRule().tagged("fraud")` has static type `Rule`, and the next link — `.with_threshold(0.9)`, defined only on `ScoredRule` — is reported as an unknown attribute. Nothing is wrong at run time; `return self` still hands back the subclass instance. The bug is purely in the contract, and it is contagious: every inherited method that names the base class widens the type again, so callers reach for `typing.cast` or a blanket ignore comment to get past it. The fix is `typing.Self` (Python 3.11, PEP 673), which a checker re-binds per call site to the receiver's actual class, so a subclass chain stays a subclass chain end to end.

code

python · 25 lines
python
from typing import Self


class Rule:
    def __init__(self) -> None:
        self.steps: list[str] = []

    def named(self, name: str) -> "Rule":  # narrow: widens every subclass chain
        self.steps.append(name)
        return self

    def tagged(self, tag: str) -> Self:  # correct
        self.steps.append(tag)
        return self


class ScoredRule(Rule):
    def with_threshold(self, t: float) -> Self:
        self.steps.append(f"threshold={t}")
        return self


ok = ScoredRule().tagged("fraud").with_threshold(0.9)
broken = ScoredRule().named("fraud")  # checker sees Rule, not ScoredRule
print(ok.steps, type(broken).__name__)

go deeper

for a junior

Know that an annotation is a promise the checker enforces, not a description it double-checks against the body. If a method declares it returns the base class, everything downstream is treated as the base class however the code actually behaves.

for a middle

Walk it through concretely: name the call that fails, say why it fails in the checker and not at run time, and give typing.Self as the fix along with the version that introduced it.

for a senior

Talk about blast radius. The narrow annotation pushes callers into casts and ignore comments, which mask unrelated errors in the same expression later, so a cheap-looking annotation costs real defect detection across every consumer.

for a principal

Frame it as a published contract. Deciding that every self-returning method in your base classes uses Self is a library-wide rule; settle it once, enforce it in review, and the suppressions it would otherwise cause never appear downstream.

### The checker believes the signature, not the body A type annotation is a promise, not a description. When a checker analyses `ScoredRule().tagged("fraud")`, it does not read the body of `tagged` to discover that it returns `self`; it reads the declared return type and uses that. So if the base class says ```python class Rule: def tagged(self, tag: str) -> "Rule": self.steps.append(tag) return self class ScoredRule(Rule): def with_threshold(self, t: float) -> "ScoredRule": self.steps.append(f"threshold={t}") return self ``` then `ScoredRule().tagged("fraud")` has static type `Rule`, and the next link, `.with_threshold(0.9)`, is an error: `Rule` has no such attribute. At run time nothing is wrong — `return self` returned the `ScoredRule` and the whole chain executes correctly. The defect lives entirely in the contract. ### Why it is contagious rather than local The failure is not one bad annotation in one place. Every inherited method that names the base class widens the expression again, so the damage compounds along a chain: reorder two links and the error moves; add a base-class method to the middle of a chain and a previously-clean subclass chain starts failing. Subclass authors cannot fix it from their side either. Overriding the method and re-annotating it with the subclass's own name works for that one method — narrowing a return type in an override is allowed — but every *other* inherited method still returns the base type, so the chain simply breaks at the first link you did not override. Re-declaring the entire fluent surface in every subclass is the maintenance cost `typing.Self` exists to abolish. ### The real damage is the workaround Consider a fraud-scoring service whose rule builder lives in a shared base class. The subclass adds `.with_features(...)` and `.with_threshold(...)`; the base class supplies `.tagged(...)` and `.named(...)` with their return types written as `Rule`. Chains stop checking, and the pragmatic fix that ships under time pressure is a `typing.cast` around the chain, or `# type: ignore` on the line. That is where a static-typing annoyance becomes a production bug. A cast asserts a type and stops the checker reasoning about the expression; an ignore comment suppresses *every* diagnostic on that line, not the one you meant. Later, a refactor drops a `.with_features(...)` link from the chain — and the suppression that was added to work around the annotation also hides the missing call. The scorer now runs on a truncated feature set, produces plausible-looking scores, and nothing fails. A checker that has been talked out of checking a chain is worse than never having annotated it, because the team believes it is covered. ### The fix, and what it costs Replace the hard-coded class name with `typing.Self`, added in Python 3.11 by PEP 673: ```python from typing import Self class Rule: def tagged(self, tag: str) -> Self: self.steps.append(tag) return self ``` The checker now re-binds `Self` per call site, so `ScoredRule().tagged("fraud")` is a `ScoredRule` and the chain continues cleanly. Run-time behaviour is byte-for-byte identical; the diff is one import and one word per method. There is one thing `Self` adds that the class name did not: it also constrains the body. Under `-> Self`, a body that returns a hard-coded `Rule()` is an error, because a subclass caller would receive a base instance. That is exactly the bug you want caught, and naming the class in the return type never caught it. Before 3.11 the same effect required a type variable bounded by the class and annotated onto the `self` parameter; libraries still supporting older interpreters carry that idiom. What was never correct, on any version, is simply writing the class's own name and hoping subclasses cope. ### How to spot it in review Two heuristics do most of the work. First, any method whose body ends in `return self` and whose return annotation is a concrete class name is a candidate — the annotation should almost always be `Self`. Second, treat a `cast` or an ignore comment wrapped around a method chain as a symptom, not a fix: it usually means somebody hit a widened return type and paid for silence rather than correcting the base class. Fixing the base class is a one-line change that deletes the suppression, and deleting the suppression is what restores checking to everything else on that line.

  • Run-time behaviour is unchanged, so why treat this as a real bug?
    Because of what people do to get past it. To silence the widened type, callers add `typing.cast` or an ignore comment, and both suppress every other diagnostic on that expression too — including a genuinely dropped call in a later refactor. A checker that has been talked out of checking a chain is worse than an unannotated one, because the team believes the chain is covered.
  • Does returning type(self)() instead of self change the analysis?
    Not by itself. The static type still comes from the annotation, so `-> Rule` on a method returning `type(self)()` is just as narrow. `Self` is what tells the checker the result matches the receiver — and it also checks the body, so hard-coding `return Rule()` under `-> Self` is reported as an error.
  • What if the subclass overrides the method and annotates its own class?
    That fixes one method and nothing else. Narrowing a return type in an override is allowed, but every other inherited method still returns the base type, so the chain breaks at the first link you did not override. You end up re-declaring the whole fluent surface in each subclass, which is precisely the maintenance cost `typing.Self` removes.

It is a mail-forwarding rule that rewrites the sender's address to head office: every reply still arrives, but nobody downstream can tell which branch office it actually came from.

saying these in an interview costs you the question

  • Says the code is broken at run time as well
  • Fixes it by overriding every method in each subclass
  • Reaches for a cast or a type-ignore instead of Self
  • Thinks the checker reads the body and ignores a wrong annotation
  • Treats it as a Liskov violation in the run-time behaviour

context