Using typing.Self for Fluent Interfaces in Python

TL;DR

Annotate each chainable method’s return type as Self (PEP 673, Python 3.11+; or typing_extensions.Self for 3.10). Static checkers then resolve the return to the concrete subclass at each call site — no TypeVar boilerplate, no base-class leakage. Never use Self on init or new.

Implementing chainable APIs in Python traditionally required verbose TypeVar bindings or inline type ignores. With Python 3.11, typing.Self provides a precise, PEP 673-compliant mechanism to annotate methods that return the instance itself. This guide details exact syntax for builder patterns, resolves common mypy and pyright inheritance errors, and demonstrates how to integrate this pattern into the broader Advanced Typing Patterns & Generics ecosystem. For developers managing complex class hierarchies, understanding typing.Self alongside Self and NotRequired Types ensures strict static analysis compliance without sacrificing API ergonomics.

typing.Self subclass resolution A method chain starting on AdvancedPipeline passes through filter (declared in DataPipeline with return type Self) and aggregate (declared in AdvancedPipeline). At each step the inferred type is AdvancedPipeline, not the base class. AdvancedPipeline ([1, 2, 3]) .filter(1) declared in DataPipeline → Self inferred: AdvancedPipeline .aggregate() declared in AdvancedPipeline → Self inferred: AdvancedPipeline result: Advanced With typing.Self — subclass type never widens to base class Legacy TypeVar("T", bound=DataPipeline) would infer DataPipeline — too wide
Self resolves to AdvancedPipeline at every step; a legacy bound TypeVar would widen to DataPipeline.
Runtime vs static analysis typing.Self has no runtime effect — Python does not enforce return types. The annotation exists purely for static checkers. At runtime, the method simply returns self; the subclass identity is preserved by Python's object model, not by the type hint.
  • Eliminates TypeVar boilerplate for chainable methods
  • Guarantees correct return types across inheritance hierarchies
  • Fully supported by mypy, pyright, and IDEs in Python 3.11+

The Problem with Legacy Self-Referencing Types

The naive annotation is to name the enclosing class as the return type — def where(self) -> "QueryBuilder". This type-checks on the base class but is silently wrong for subclasses: the checker takes the annotation literally and treats the result as exactly QueryBuilder, so any subclass-only method later in the chain fails with mypy’s [attr-defined] ("QueryBuilder" has no attribute "limit"). The concrete type is thrown away at the first chained call. Before PEP 673 the accepted workaround was a bound TypeVar threaded through self, which approximates self-returning behavior but is verbose: you declare T = TypeVar("T", bound="QueryBuilder"), annotate self: T on every chainable method, and forward-reference the bound as a string because the class is not yet defined. Miss the self: T on one method in a long chain and the type quietly collapses back to the base with no diagnostic.

PEP 673 standardizes this behavior for static analysis. It removes the need for manual type variable scoping, the per-method self: T, and the forward-referenced bound — Self is an implicitly bound type variable that both mypy and pyright resolve to the receiver’s concrete type at each call site.

Legacy bound TypeVar versus Self The left panel shows the verbose bound-TypeVar idiom that must be redeclared on every method; the right panel shows the single Self annotation that replaces it. Before — bound TypeVar T = TypeVar("T", bound="Builder") def where(self: T) -> T repeat self: T on every method forward-referenced string bound miss one and the chain breaks After — Self (PEP 673) def where(self) -> Self no TypeVar, no self: T resolves to concrete subclass inherited without re-annotation one annotation, Python 3.11+
The bound-TypeVar idiom needs a declaration, a forward-referenced bound, and a self: T on each method; Self collapses all of that into one annotation.
# For Python 3.10: from typing_extensions import Self
from typing import Self

# Legacy (Python <3.11)
# T = TypeVar("T", bound="QueryBuilder")
# class QueryBuilder:
#     def where(self, condition: str) -> "T":
#         return self  # Static checkers flag: can't return Self as T

# Modern (Python 3.11+)
class QueryBuilder:
    def where(self, condition: str) -> Self:
        return self

The covariance pitfall is worth spelling out. A bound TypeVar used in argument position is unsound because parameters are contravariant, and checkers will warn; using it only for self and the return type is safe but relies on the author getting self: T right everywhere. Self sidesteps the whole variance question because it is not a user-declared variable — the checker owns its binding. Self was added to typing in Python 3.11 (mypy has supported it since 1.0, pyright since 1.1.184); on 3.7–3.10 import it from typing_extensions, which mypy and pyright both recognize as equivalent. Verify the behavior with reveal_type(PagedQuery().where("x")) — under Self it prints PagedQuery, under the naive base annotation it prints QueryBuilder.

Exact Syntax for Chainable Builder Methods

Each method explicitly declares Self as its return type, guaranteeing the exact instance type propagates through the chain. Do not annotate __init__ with Self — constructors implicitly return None, and annotating them -> Self is reported by pyright and by mypy under --strict. The rule is mechanical: a method that ends in return self (or return type(self)(...) for an immutable/copy-on-write builder) is annotated -> Self; everything else keeps its ordinary return type.

Self-returning builder pipeline Each chained call, filter then transform, is annotated returning Self, so the inferred type stays DataPipeline down the whole vertical chain. DataPipeline([1, 2, 3, 4]) start: DataPipeline .filter(2) -> Self inferred: DataPipeline .transform(fn) -> Self inferred: DataPipeline result DataPipeline — passes --strict
Every step is annotated -> Self, so the inferred type never widens; the whole chain stays DataPipeline.

The following implementation passes mypy --strict and pyright --strict without casting:

from typing import Self, Callable

class DataPipeline:
    def __init__(self, data: list[int]) -> None:
        self.data = data

    def filter(self, threshold: int) -> Self:
        self.data = [x for x in self.data if x > threshold]
        return self

    def transform(self, func: Callable[[int], int]) -> Self:
        self.data = [func(x) for x in self.data]
        return self

# Correctly inferred as DataPipeline
pipeline = DataPipeline([1, 2, 3, 4]).filter(2).transform(lambda x: x * 10)

CI-Ready Configuration:

# pyproject.toml
[tool.mypy]
strict = true
python_version = "3.11"

[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.11"

Two edge cases catch people out. First, Self constrains the checker but not the interpreter: if a method typed -> Self actually returns a base instance — return DataPipeline(...) inside a method of a subclass context — mypy reports [return-value] (Incompatible return value type), because a base object is not guaranteed to be Self. Return self, or type(self)(...) when you build a fresh same-typed object. Second, for an immutable builder that returns a copy, type(self)(...) is correct while a hardcoded DataPipeline(...) is not — the former preserves the subclass, the latter narrows to the base and re-triggers [return-value] in subclasses. Confirm the whole chain with reveal_type(pipeline); both checkers print DataPipeline here. When targeting 3.10 you must set python_version/pythonVersion accordingly and import Self from typing_extensions, or mypy raises [attr-defined] on typing.Self and pyright reports it as unknown.

Inheritance and Subclass Return Type Safety

Self automatically propagates correct types to subclasses. You do not need to re-annotate methods or bind explicit TypeVar constraints in derived classes. Static checkers resolve the concrete subclass type at the call site, preventing base-class return type leakage during cross-module imports — the inherited filter and transform, declared on the base as -> Self, are re-read as returning the subclass with no changes to the derived class.

Self resolution across an inheritance tree DataPipeline defines filter returning Self; AdvancedPipeline inherits it without re-annotation, and a chained call on AdvancedPipeline is inferred as AdvancedPipeline. DataPipeline (base) filter(), transform() -> Self inherits, no re-annotation AdvancedPipeline (subclass) adds aggregate() -> Self call site infers AdvancedPipeline AdvancedPipeline([1,2,3]).filter(1).aggregate() Self never widens to the base DataPipeline
The base declares -> Self once; the subclass inherits it and the call site is inferred as AdvancedPipeline, never the base.
class AdvancedPipeline(DataPipeline):
    def aggregate(self) -> Self:
        self.data = [sum(self.data)]
        return self

# Correctly inferred as AdvancedPipeline, not DataPipeline
result = AdvancedPipeline([1, 2, 3]).filter(1).transform(lambda x: x).aggregate()

The same mechanism powers alternative constructors. A @classmethod that returns an instance should annotate its first parameter cls: type[Self] and return cls(...); called on AdvancedPipeline, AdvancedPipeline.build(...) is then inferred as AdvancedPipeline, not DataPipeline. This is exactly what a bare class-name return type cannot express. Self also composes with generics and containers — def clones(self, n: int) -> list[Self] types a method returning a list of same-typed instances — and it behaves predictably under multiple inheritance: the receiver’s runtime type, not the MRO order, drives resolution. Because the checker binds Self at the call site rather than at definition time, a method defined in one module and inherited in another still resolves to the importing subclass, which is where the older forward-referenced bound TypeVar frequently lost precision. See Self and NotRequired Types for how Self interacts with TypedDict-style structural APIs.

Integrating with Protocols and Async Fluent APIs

Self works seamlessly in async def methods — the return annotation remains identical. Protocol compliance requires explicit Self declarations to enforce structural subtyping.

Structural match against a Self-returning async Protocol StreamProcessor implements async process returning Self and therefore satisfies the AsyncChainable protocol structurally, and mypy, pyright, and ruff each treat it accordingly. Protocol AsyncChainable async process(self) -> Self no inheritance required class StreamProcessor async process(self) -> Self matching signature satisfies structurally mypy --strict accepts the match pyright --strict accepts the match ruff (UP rules) syntax only, no inference
StreamProcessor matches AsyncChainable by shape alone; both type checkers accept it, while ruff polices only the annotation syntax.

Checker Behavior Notes:

  • mypy: Fully supports Self in protocols and async methods under --strict.
  • pyright: Handles Self natively. Flags protocol mismatches aggressively.
  • ruff: Enforces PEP 673 syntax consistency via the UP rule family but does not perform type inference.
from typing import Protocol, Self
import asyncio

class AsyncChainable(Protocol):
    async def process(self) -> Self: ...

class StreamProcessor:
    async def process(self) -> Self:
        await asyncio.sleep(0)
        return self

Note: StreamProcessor satisfies AsyncChainable structurally because it implements process with a compatible signature — it does not subclass the protocol. Inside the protocol, -> Self means “an instance of whatever class implements this protocol”, so AsyncChainable describes any type whose process returns its own type; the concrete StreamProcessor.process returning Self is compatible because its Self resolves to StreamProcessor, which is exactly what the protocol promises callers.

async def changes nothing about the annotation but everything about consumption: process is typed -> Self, yet the awaited value is what carries the type, so reveal_type(await proc.process()) prints StreamProcessor. Do not write the return as -> Coroutine[Any, Any, Self] by hand — the async def keyword already wraps Self in the coroutine, and spelling both is a [return-value] mismatch. The same holds for __aenter__, which pairs with Self to keep async with StreamProcessor() as p: binding p to the concrete subclass.

Two protocol caveats are worth knowing. First, if you decorate the protocol with @runtime_checkable to allow isinstance(obj, AsyncChainable), the runtime check only verifies that the method exists — it cannot see the Self return type or whether it is async, so a class with a synchronous process returning int would still pass isinstance while failing the static check. Structural typing is enforced statically, not at runtime. Second, Self in a protocol is the idiomatic way to type “returns something of my own type”; using a bound TypeVar on the protocol instead forces every implementer to re-bind it and tends to produce spurious variance diagnostics. For the linter side, ruff’s UP (pyupgrade) rules will rewrite typing_extensions.Self imports and other legacy forms toward the modern spelling once your target-version is 3.11+, but ruff performs no type inference — it will not tell you whether a Self annotation is correct, only whether it is written in the current style.

Common Mistakes

The three recurring Self errors in fluent code are summarised in the grid below: the flagged form, why the checker rejects it, and the correct spelling.

Common Self mistakes and their corrections Annotating a constructor with Self, mixing Self with a bound TypeVar, and importing typing.Self on Python 3.10 each fail, with the correct form given in the last column. avoid why it fails instead def __init__(...) -> Self ctor returns None -> None Self + TypeVar bound conflicting binding Self alone typing.Self on 3.10 ImportError typing_extensions
Keep constructors -> None, use Self without a competing TypeVar, and back-import from typing_extensions below Python 3.11.
  1. Using Self in __init__ or __new__ methods Self is strictly for methods returning the instance after initialization. __init__ implicitly returns None, so annotating it -> Self is flagged (mypy under --strict, pyright always). __new__ is the genuine exception — it does return an instance, and typing it -> Self is correct for a class that customizes allocation while keeping subclass identity.

  2. Mixing Self with explicit TypeVar bounds in the same method Self replaces the need for TypeVar("T", bound="Base"). Combining them — for example def where(self: T) -> Self — creates two competing bindings for the receiver that confuse static analyzers. Pick one mechanism per method; on 3.11+ that should be Self.

  3. Forgetting typing_extensions fallback for Python <3.11 typing.Self is available only from Python 3.11+. Projects targeting older versions must import Self from typing_extensions (from typing_extensions import Self) to avoid ImportError at runtime and an [attr-defined] on typing.Self from mypy. Keep the checker’s python_version/pythonVersion at your real floor so CI models the older interpreter.

  4. Returning a hardcoded base instance under -> Self return DataPipeline(...) inside a -> Self method is [return-value] in subclasses. Return self for in-place builders or type(self)(...) for copy-on-write builders, both of which preserve the concrete subclass.

FAQ

Does typing.Self work with mypy strict mode? Yes. mypy fully supports typing.Self in strict mode (since mypy 1.0). It correctly validates return types, handles inheritance, and flags mismatched chainable method signatures — including catching a -> Self method that returns a base instance as [return-value]. pyright has supported it since 1.1.184, and the two agree on the common builder cases; verify any edge case with reveal_type on both.

How to handle typing.Self in Python 3.10 or lower? Install typing_extensions and import Self from there (from typing_extensions import Self). The runtime behaviour is identical and both checkers recognise the backport as equivalent to typing.Self. Set python_version = "3.10" (mypy) / pythonVersion = "3.10" (pyright) so the checker still rejects a bare from typing import Self, which is the failure a 3.10 user would actually hit.

Can Self be used in class methods (@classmethod)? Not as a bare annotation on self. For class methods returning an instance of the class, annotate the first parameter as cls: type[Self] and return cls(...). This correctly propagates the subclass type when the classmethod is called on a subclass — AdvancedPipeline.build() is then inferred AdvancedPipeline, not the base. Typing the receiver cls: Self instead is a [misc] error, since cls is the class object, not an instance.

Is -> Self safe on __enter__ and __aenter__? Yes, and it is the recommended annotation for both. def __enter__(self) -> Self makes with SubBuilder() as b: bind b to the subclass, and async def __aenter__(self) -> Self does the same for async with. This is one of the clearest wins of Self over a bare class name, which would strip the subclass type from the bound context variable.

Does Self behave correctly with mixins and multiple inheritance? Yes. Because the checker resolves Self to the runtime class of the receiver rather than the class where the method is textually defined, a chainable method written once on a mixin returns the concrete class that mixes it in — class Query(FilterMixin, PageMixin) gets Query back from every inherited -> Self method regardless of MRO order. This is precisely the case where the old forward-referenced bound TypeVar was most fragile, since each mixin needed its own compatible TypeVar declaration.

Back to Self and NotRequired Types