Skip to content

← The Type System as a Design Tool step 18 of 24

Medium Primitives

Self and the fluent query builder

Every ORM, HTTP client and query builder you have used depends on this, and gets it wrong at least once.

class QueryBuilder:
    def where(self, clause: str) -> "QueryBuilder":
        self._wheres.append(clause)
        return self

That annotation is a lie the checker believes. At runtime where returns self, which for a PagedQueryBuilder is a PagedQueryBuilder. Statically it is a QueryBuilder, so the very first chained call downgrades the type, and every subclass-specific method after it is a type error:

PagedQueryBuilder.from_table("t").where("x=1").page(2, 10)
#                                              ^^^^ error: "QueryBuilder" has no attribute "page"

typing.Self (PEP 673, 3.11) fixes it in one word: -> Self means “the type of self at the call site”, so the subclass’s type survives the whole chain.

The four places Self belongs

  1. Fluent builders — every method that returns self.
  2. Alternative constructors@classmethod def from_table(cls, ...) -> Self returning cls(...). Without it, PagedQueryBuilder.from_table("t") is statically a QueryBuilder and the chain is dead before it starts.
  3. __enter__ — a context manager subclass otherwise loses its type inside the with block.
  4. Clone / copy methodsdef copy(self) -> Self.

And where it is wrong

Self is a promise. If a method always constructs the base class explicitlyreturn QueryBuilder(self._table) rather than type(self)(...) or cls(...) — then annotating it -> Self is a lie the checker cannot catch, and a subclass will get an object of the wrong type with a static type that says otherwise. Annotate that method with the concrete class it actually builds.

Self is also invalid in a @staticmethod (there is no self), at module level, in a type alias, in a metaclass, and subscripted (Self[int]).

The task

class QueryBuilder:
    @classmethod
    def from_table(cls, table: str) -> Self: ...
    def where(self, clause: str) -> Self: ...
    def order_by(self, column: str) -> Self: ...
    def limit(self, count: int) -> Self: ...
    def build(self) -> str: ...

class PagedQueryBuilder(QueryBuilder):
    def page(self, number: int, size: int) -> Self: ...
    @override
    def build(self) -> str: ...

QueryBuilder.build assembles, in this order and space-joined, omitting any part that was never set:

SELECT * FROM <table> WHERE <c1> AND <c2> ORDER BY <col> LIMIT <n>

PagedQueryBuilder.page(number, size) records a 1-based page, clears any limit set earlier, and its build appends LIMIT <size> OFFSET <(number-1)*size> to whatever the base class produced.

solve(table, wheres, order, limit, page, per_page) builds two queries and returns both: a plain QueryBuilder chain (applying order_by when order is non-empty and limit when limit > 0), and a PagedQueryBuilder chain (applying page when page > 0).

The line that proves it

paged = PagedQueryBuilder.from_table(table)
for clause in wheres:
    paged = paged.where(clause)
...
paged = paged.page(page, per_page)

paged must still be statically a PagedQueryBuilder after from_table and after every where. If any of those methods returns QueryBuilder, the final line does not type-check — which is the entire assertion, and the reason your solution will not pass the gate without Self.

@override on PagedQueryBuilder.build is the other half of the same discipline: it makes the checker verify that a method by that name really does exist on the base. Decorator ordering matters — @override goes innermost, closest to the def.