Skip to content

← Structural Typing and the Hard Parts step 17 of 24

Medium Framework

Self-types, and restricting a method to one parameterisation

typing.Self solves the fluent-builder problem: a chained method must return the subclass, not the base, or .audit() disappears halfway down the chain. It is the right default and it is one word long.

What Self cannot do is the subject here. It cannot make a method conditionally available.

Annotating self to restrict a method

You are allowed to annotate self explicitly, and when you do, the method only type-checks on receivers matching that annotation:

class QueryBuilder[T]:
    def like[S: QueryBuilder[str]](self: S, pattern: str) -> S: ...

like now exists on QueryBuilder[str] and is a static error on QueryBuilder[int] — the checker reports it as an invalid self argument. This is how typeshed keeps str-only operations off bytes paths, and it is the tool to reach for when “this method only makes sense for some instantiations” is the actual invariant.

Note the extra method-scoped type parameter S. Writing self: QueryBuilder[str] alone would widen the receiver: chaining off it would lose the subclass. Binding S to the receiver and returning S keeps both properties — restricted and subclass-preserving.

Your task

Make the fluent API precise:

  • from_table is a @classmethod returning Self
  • where and limit return Self
  • like is restricted to QueryBuilder[str] receivers, and preserves the receiver type
  • build() renders "SELECT * FROM <table>" followed by the accumulated clauses, space-separated

Clause formats: where appends f"WHERE {clause}", limit appends f"LIMIT {count}", like appends f"LIKE '{pattern}'".

def solve(table: str, pattern: str, limit: int) -> list[str]:

returns three strings:

  1. QueryBuilder[str].from_table(table).where("a = 1").like(pattern).limit(limit).build()
  2. QueryBuilder[int].from_table(table).where("id = 2").limit(limit).build()
  3. AuditedQueryBuilder[str].from_table(table).where("b = 3").like(pattern).audit()

Where the type system does the work

Line 3 is the assertion. audit() exists only on AuditedQueryBuilder. If where returns QueryBuilder[T] instead of Self, the chain degrades to the base class at the first call and .audit() stops resolving — which is exactly the bug the starter has, and exactly the bug fluent APIs ship with when nobody annotates them.

Line 2 is the other half: int_q never touches like, because it cannot.