How was a self-returning Python method typed before typing.Self existed?
answer
- The receiver itself carried the annotation
- One name per hierarchy, repeated everywhere
- Bounded so it cannot solve too wide
- Replaced by one special form in 3.11
basics
~10 sWith a type variable bounded by the class and annotated on the receiver: B = TypeVar("B", bound="Builder") and def add(self: B, ...) -> B. A checker then solves B to the caller's actual class.
solid answer
~40 sYou declared a type variable whose bound was the class, annotated the `self` parameter with it, and returned it: `B = TypeVar("B", bound="Builder")`, then `def add(self: B, part: str) -> B: ...`. Annotating `self` is the key move — it turns the receiver into an inference site, so a call on a subclass solves the variable to the subclass and the chain keeps its type. It works, but it costs a module-level name per hierarchy, must be repeated on every method, and reads as advanced typing for a simple statement; it is also easy to break by omitting the bound or reusing one variable across two hierarchies. Python 3.11's `typing.Self` (PEP 673) replaced it. You still meet it in libraries supporting older interpreters, and in signatures that genuinely relate two different types.
code
python · 22 linesfrom typing import TypeVar
B = TypeVar("B", bound="Builder")
class Builder:
def __init__(self) -> None:
self.parts: list[str] = []
def add(self: B, part: str) -> B:
self.parts.append(part)
return self
class TaggedBuilder(Builder):
def tag(self, label: str) -> "TaggedBuilder":
self.parts.append(label)
return self
# a checker solves B to TaggedBuilder, so .tag() is still visible after .add()
print(TaggedBuilder().add("score").tag("fraud").parts)go deeper
You are unlikely to be asked to write this. Recognising def add(self: B, ...) -> B as "returns the same class you called it on" is enough so that unfamiliar library code does not stop you.
Be able to read the idiom and say why the annotation on self matters — it is the inference site the checker solves from. Then state what you would write today instead, and from which Python version.
Show migration judgement: when the supported-interpreter floor lets you delete the type variables, when a compatibility shim is cheaper, and which remaining variables genuinely cannot collapse to Self because they relate two different types.
This is a dependency-floor decision rather than a style one. Raising a shared library's minimum Python version is coordination work across every consumer, and the typing cleanup it unlocks is a benefit to weigh, not the reason to do it.
### The idiom Before `typing.Self` existed, expressing "this returns whatever class you called it on" required a type variable bounded by the class, annotated onto the `self` parameter and reused as the return type: ```python from typing import TypeVar B = TypeVar("B", bound="Builder") class Builder: def add(self: B, part: str) -> B: self.parts.append(part) return self ``` At a call site the checker solves `B` from the receiver. `Builder().add("x")` solves it to `Builder`; `TaggedBuilder().add("x")`, where `TaggedBuilder` subclasses `Builder`, solves it to `TaggedBuilder`, so a subclass-only `.tag(...)` after it still checks. That is the same outcome `Self` gives today, reached the long way round. ### Why the annotation on `self` is the whole trick The single most common way to get this wrong is to annotate only the return type — `def add(self, part: str) -> B:` — and expect it to work. It does not. A type variable is solved from the places it appears in the *inputs*; with no occurrence in a parameter, the checker has nothing to infer from at the call site and falls back to the variable's bound, which is the base class. You get exactly the widening you were trying to avoid, with more machinery. Putting `B` on `self` turns the receiver into an inference site: the argument implicitly passed to the method carries the type that determines the answer. That is why the idiom looks strange — annotating `self` at all is unusual — and it is the part worth being able to explain when you meet the pattern in someone else's code. The bound matters too. An unbounded variable would let `B` solve to anything at all, and the body's `self.parts` access would not check, since an arbitrary `B` has no such attribute. Bounding it to the class is what lets the body use the class's own members while still tracking the caller's more specific type. The bound is written as a string here because on Python 3.13 and earlier the class does not yet exist when the module-level `TypeVar(...)` call runs. ### What it cost The idiom works, and it stayed the recommended answer for years, but the costs are real. Each hierarchy needs its own module-level variable, sitting in the namespace with a name that means nothing to a reader. The annotation has to be repeated on every self-returning method rather than declared once. It is easy to get subtly wrong — omit the bound, share one variable across two unrelated hierarchies, or forget the `self` annotation and quietly lose the benefit while keeping the noise. And it reads as advanced typing for what is conceptually the simplest possible statement about a method. Python 3.11 replaced it wholesale with `typing.Self` (PEP 673), which needs no declaration, cannot be shared across hierarchies by accident, and carries its meaning in its name. Python 3.12 later added a shorter inline spelling for declaring type variables, which trims the boilerplate but does not change the argument: for the self-type case, `Self` is the answer. ### Why you still meet it Two reasons, and only one of them is inertia. The first is the supported-interpreter floor. A shared internal library cannot raise its minimum Python version faster than its consumers move, and a package sitting under a 17-service dependency graph moves at the pace of the slowest service in it. Until the floor is 3.11, the choices are the old idiom or a compatibility shim that imports `Self` from a backport when available — and once the floor finally moves, rewriting working annotations rarely outranks anything else on the list. The second is that a bounded variable genuinely does things `Self` cannot. `Self` has one fixed meaning per class, so it can only say "the receiver's type". If a signature needs to mention the same unknown type in more than one independent position — accept a second argument of the receiver's exact class and return a collection of that class, or relate the receiver's type to a separate parameter's type — you need a name you can write more than once. Those cases are rarer than the self-returning method, but they are why the older mechanism is not simply obsolete. ### How to talk about it If asked, read the idiom aloud correctly ("the receiver is annotated with a variable bounded by the class, so the checker solves it to the caller's actual class"), say what you would write today and from which version, and then show the migration judgement: mechanical replacement wherever the floor allows it, a shim where it does not, and a deliberate decision to keep a named variable only in the signatures that genuinely relate two types. That last distinction is the one that separates a candidate who has read about `Self` from one who has migrated a codebase to it.
- Why does the old idiom annotate self rather than only the return type?Because the variable needs somewhere to be inferred from. With `-> B` alone the checker has no information about `B` at the call site and solves it to the bound, which is the base class — exactly the widening you were trying to avoid. Putting `B` on `self` makes the receiver the inference site, so a subclass call solves it to the subclass.
- Is there any case Self cannot cover that the bounded-variable form can?Yes, whenever the relationship is not simply "the same class". If a signature must mention the same unknown type in more than one independent position — accept a second argument of the receiver's exact class and return a collection of it, or relate the receiver's type to a separate parameter's type — you need a name you can write repeatedly. `Self` has one fixed meaning; a named variable is a slot you control.
- Why does the idiom still turn up in current code?Because a shared library cannot raise its minimum interpreter faster than its consumers move, and a package sitting under a 17-service dependency graph moves at the pace of the slowest service. Until the floor is 3.11 the choice is the old idiom or a compatibility shim, and once the floor moves, rewriting working annotations rarely outranks anything else.
saying these in an interview costs you the question
- Annotates only the return type and omits self
- Leaves the type variable unbounded, so anything solves it
- Shares one type variable across unrelated class hierarchies
- Claims the older idiom stopped working in Python 3.11
- Says Self is a drop-in for every bounded type variable