Using typing.Self for Fluent Interfaces in Python
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 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
TypeVarboilerplate 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.
# 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.
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.
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.
Checker Behavior Notes:
mypy: Fully supportsSelfin protocols and async methods under--strict.pyright: HandlesSelfnatively. Flags protocol mismatches aggressively.ruff: Enforces PEP 673 syntax consistency via theUPrule 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.
-
Using
Selfin__init__or__new__methodsSelfis strictly for methods returning the instance after initialization.__init__implicitly returnsNone, so annotating it-> Selfis flagged (mypy under--strict, pyright always).__new__is the genuine exception — it does return an instance, and typing it-> Selfis correct for a class that customizes allocation while keeping subclass identity. -
Mixing
Selfwith explicitTypeVarbounds in the same methodSelfreplaces the need forTypeVar("T", bound="Base"). Combining them — for exampledef 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 beSelf. -
Forgetting
typing_extensionsfallback for Python <3.11typing.Selfis available only from Python 3.11+. Projects targeting older versions must importSelffromtyping_extensions(from typing_extensions import Self) to avoidImportErrorat runtime and an[attr-defined]ontyping.Selffrom mypy. Keep the checker’spython_version/pythonVersionat your real floor so CI models the older interpreter. -
Returning a hardcoded base instance under
-> Selfreturn DataPipeline(...)inside a-> Selfmethod is[return-value]in subclasses. Returnselffor in-place builders ortype(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.