skip to content

How do you annotate a Python @classmethod alternative constructor so subclasses get their own type?

level: middleimportance: should knowfreq 34%

answer

  1. Build from the receiver, not a fixed name
  2. cls is bound to the actual class called
  3. The same annotation suits __enter__
  4. The promise extends to subclass constructors

basics

~10 s

Annotate the return as typing.Self and construct with cls(...), never with the class's own name. A checker then types CappedScore.from_percent(...) as CappedScore, because cls is implicitly type[Self].

solid answer

~40 s

Declare it `@classmethod def from_percent(cls, pct: float) -> Self:` and build the object with `cls(...)`. `cls` is bound to the class the call was made on, so a subclass call constructs a subclass instance, and `Self` is what tells the checker so — annotate it `-> Score` instead and every subclass caller gets the base type back. The same rule covers `__enter__`, which should return `Self` so a `with` block binds the subclass type, and any method returning a fresh sibling object via `type(self)(...)`. The real hazard is the constructor contract: promising `Self` promises that `cls(...)` works for every subclass, so a subclass adding a required `__init__` parameter breaks it. Checkers exempt `__init__` from override checks, so nothing warns — which is why library base classes often construct through an overridable hook instead.

code

python · 20 lines
python
from typing import Self


class Score:
    def __init__(self, value: float) -> None:
        self.value = value

    @classmethod
    def from_percent(cls, pct: float) -> Self:
        return cls(pct / 100)


class CappedScore(Score):
    def capped(self) -> Self:
        self.value = min(self.value, 1.0)
        return self


built = CappedScore.from_percent(250)
print(type(built).__name__, built.capped().value)

go deeper

for a junior

Remember the shape: an alternative constructor is a @classmethod whose first parameter is cls, and it should build with cls(...). That single habit is most of what makes it behave correctly for subclasses.

for a middle

Explain both halves — cls(...) produces the right object at run time, -> Self gives the right type to the checker — and note that __enter__ wants the same annotation for the same reason.

for a senior

Raise the contract you are signing. -> Self on a classmethod asserts the base constructor signature holds for every subclass, and since checkers exempt __init__ from override checks, that assertion is backed by convention or an explicit construction hook, not by tooling.

for a principal

Decide whether subclassing is part of the public API at all. If it is, the base constructor's signature is frozen for everyone; if it is not, factory functions and a closed hierarchy keep the freedom to change it later.

### The two halves of an alternative constructor An alternative constructor in Python is a `@classmethod` that builds and returns an instance — `from_row`, `from_json`, `parse`, `from_percent`. Getting it right for subclasses has a run-time half and a static half, and both have to be done. The run-time half is to construct through `cls`, never through a hard-coded class name. Because a classmethod's first argument is bound to the class the call was made on, `CappedScore.from_percent(250)` binds `cls` to `CappedScore`, and `return cls(pct / 100)` therefore produces a `CappedScore`. Write `return Score(pct / 100)` instead and the subclass silently gets a base instance — a run-time defect, not merely a typing one, and one that inheritance was supposed to prevent. The static half is to annotate the return as `typing.Self`, the special form added in Python 3.11 by PEP 673: ```python from typing import Self class Score: def __init__(self, value: float) -> None: self.value = value @classmethod def from_percent(cls, pct: float) -> Self: return cls(pct / 100) ``` Inside the method `cls` is implicitly typed `type[Self]`, so `cls(...)` produces a `Self` and the annotation is satisfied. At a call site the checker re-binds `Self` to the receiver: `Score.from_percent(...)` is a `Score`, `CappedScore.from_percent(...)` is a `CappedScore`, and a chained subclass-only method after it still checks. Annotate the return as `-> Score` instead and every subclass caller gets the base type back — the same widening bug that breaks fluent chains, just arriving through the constructor. ### `__enter__` is the same shape A context manager whose `__enter__` returns the receiver wants the identical annotation, and for the identical reason: ```python def __enter__(self) -> Self: return self ``` With `-> Self`, `with CappedScore(0.5) as s:` binds `s` to `CappedScore`. With the base class named there, `s` is a `Score` for the entire body of every `with` block in every subclass — which is a worse blast radius than a broken chain, because the widened name is then used repeatedly rather than once. (The protocol itself — pairing with `__exit__`, suppression semantics, re-entrancy — is a separate subject; the point here is only which type the annotation should carry.) The same rule extends to any method that returns a *fresh* sibling object rather than the receiver, built as `type(self)(...)`. `Self` promises the type, not object identity, so that is a correct use. ### The contract you are signing This is where the interesting judgement lives. Declaring `-> Self` on a classmethod is an assertion that `cls(...)` works for **every** subclass — that is, that the base constructor's signature is inherited unchanged, or at least remains callable with the arguments the base method passes. If a subclass adds a required `__init__` parameter, the inherited classmethod raises `TypeError` at run time for that subclass, while the annotation continues to promise a valid `Self`. Almost nothing catches this. Type checkers deliberately exempt `__init__` and `__new__` from the override-compatibility rules they apply to ordinary methods, precisely because constructors routinely differ between a base class and its subclasses. So the promise made by `-> Self` on a classmethod is enforced by convention, not by tooling. Library base classes handle that in one of three ways. They document the constructor signature as part of the subclassing contract and treat changing it as a breaking change. They route construction through an overridable hook — a classmethod like `_create` that a subclass can redefine — so the alternative constructor never calls `__init__` positionally. Or they close the hierarchy: keep construction behind module-level factory functions and do not invite subclassing at all, which leaves the constructor free to change later. ### Rules of thumb Use `-> Self` on: a `@classmethod` that constructs via `cls`, an `__enter__` that returns the receiver, any method that returns `self`, and any method that returns `type(self)(...)`. Do not use it on a `@staticmethod` — there is no receiver for it to bind to and checkers report it as an error; if the class is needed, make the method a classmethod. Do not annotate `cls` explicitly; the checker supplies `type[Self]` and writing it out by hand mostly produces noise or mistakes. And do not read `-> Self` as a guarantee the interpreter enforces. Like every annotation it is erased — since Python 3.14, annotations are not even evaluated until something asks for them (PEP 649). It is a promise to readers and to checkers, and on a classmethod it is a promise about your subclasses' constructors as much as about your own return statement.

  • What breaks if a subclass adds a required __init__ parameter?
    The inherited alternative constructor stops working: `cls(pct / 100)` raises `TypeError` at run time for that subclass while the annotation still promises a valid `Self`. Checkers exempt `__init__` and `__new__` from override-compatibility rules, so nothing warns. Base classes avoid it by documenting the constructor as part of the subclassing contract, or by constructing through an overridable hook.
  • Why annotate __enter__ with Self rather than the class name?
    So `with SubClass() as handle:` binds `handle` to the subclass. `__enter__` returning the receiver is exactly the pattern `Self` exists for, and naming the base class there is the same widening bug as in a fluent chain — worse, in fact, because the widened name is then used repeatedly through the body of every `with` block instead of once mid-chain.
  • Does typing.Self work on a staticmethod?
    No. A static method has no receiver, so there is nothing for `Self` to bind to and checkers report it as an error. If the method needs the class it should be a `@classmethod`, where `cls` is available and implicitly typed `type[Self]`.

saying these in an interview costs you the question

  • Constructs with the class's own name inside a classmethod
  • Annotates the alternative constructor's return as the base class
  • Thinks cls must be annotated explicitly for this to work
  • Assumes a checker verifies every subclass constructor signature
  • Puts Self on a staticmethod that has no receiver

context