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

Legacy patterns relied on bound TypeVar declarations to approximate self-returning behavior. This approach introduces covariance pitfalls and requires redeclaring the TypeVar in every subclass that overrides a method. Static checkers struggle to resolve the exact subclass type when methods are chained across modules.

PEP 673 standardizes this behavior for static analysis. It removes the need for manual type variable scoping.

# 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

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.

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"

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.

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()

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 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.

Common Mistakes

  1. Using Self in __init__ or __new__ methods Self is strictly for methods returning the instance after initialization. Constructors implicitly return None; annotating them with Self triggers static analysis errors.

  2. Mixing Self with explicit TypeVar bounds in the same method Self replaces the need for TypeVar("T", bound="Base"). Combining them creates conflicting type constraints that confuse static analyzers.

  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 to avoid ImportError.

FAQ

Does typing.Self work with mypy strict mode? Yes. mypy fully supports typing.Self in strict mode. It correctly validates return types, handles inheritance, and flags mismatched chainable method signatures.

How to handle typing.Self in Python 3.10 or lower? Install typing_extensions and import Self from there. The runtime behavior is identical, and static checkers recognize the backport.

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.

Back to Self and NotRequired Types