I wanted a generic pagination function for a GraphQL API, but the generated types wouldn't let me write one. I was using ariadne-codegen to generate typed Python stubs from a GraphQL schema — the types are correct, but you can't fully control their shape.

Each endpoint returned a discriminated union — a type that could be one of several unrelated types. For one endpoint, that looked like this:

type EndpointAResult = EndpointASuccessItems | EndpointAAuthorizationError | EndpointANotFoundError

@dataclass
class EndpointASuccessItems:
    items: list[EndpointASuccessType]
    page_info: EndpointAPageInfo

The challenge was that each error type was a distinct class with no common base class. Without a shared parent, there's no obvious way to write an isinstance check in a generic function that a type checker can use to narrow the type.

This is the problem structural subtyping solves. Since Python 3.8, Protocols let you define a type by what it has, not what it inherits from. For me, having spent most time writing Go, this is a familiar concept very much akin to interfaces.

All the generated error types shared the same fields, but had no common parent:

@dataclass
class EndpointAAuthorizationError:
    error_code: str
    error_message: str

And similarly, every paged response had the same pagination structure:

@dataclass
class EndpointAPageInfo:
    has_next_page: bool
    end_cursor: str | None

So I defined Protocols that captured these shared shapes:

@runtime_checkable
class ErrorResponse(Protocol):
    @property
    def error_code(self) -> str: ...
    @property
    def error_message(self) -> str: ...

class PageInfo(Protocol):
    @property
    def has_next_page(self) -> bool: ...
    @property
    def end_cursor(self) -> str | None: ...

class PagedResult[T](Protocol):
    @property
    def page_info(self) -> PageInfo: ...
    @property
    def items(self) -> list[T]: ...

By default, Protocols are a purely static concept — your type checker understands them, but isinstance doesn't. Adding @runtime_checkable lets you use isinstance(result, ErrorResponse) at runtime, which is what makes narrowing possible.

With those Protocols in place, the generic pagination function is now possible:

_DEFAULT_PAGE_SIZE = 100

def paginate[T](
    fetch: Callable[[int, str | None], PagedResult[T] | ErrorResponse],
    page_size: int = _DEFAULT_PAGE_SIZE,
) -> Iterator[T]:
    after: str | None = None
    while True:
        result = fetch(page_size, after)
        if isinstance(result, ErrorResponse):
            raise APIError(f"Query failed [{result.error_code}]: {result.error_message}")
        yield from result.items
        if not result.page_info.has_next_page:
            break
        after = result.page_info.end_cursor

This handles pagination for any endpoint whose response matches the Protocol and the type checker correctly narrows result to PagedResult[T] after the isinstance check. Aside from being a useful static safety check, it also gives us useful autocompletion in our editor.

Calling it is straightforward:

for item in paginate(lambda first, after: client.get_items(first=first, after=after)):
    print(item)

Python's type system still has gaps that are hard to ignore if you've spent time with TypeScript. Protocols are a potentially underused feature. Any time you're working with codegen output, third-party libraries, or multiple classes that happen to share a structure, Protocols let you write generic, type-safe code without reaching for inheritance or wrapper classes.