How do you write a generic typing.Protocol and what decides its variance?
answer
- One protocol, a family of shapes
- Where the parameter appears decides everything
- Return-only versus parameter-only positions
- Read-write member forces invariance
- Inline type parameters infer it since 3.12
basics
~20 sDeclare type parameters on the protocol, as in class SinkT since Python 3.12, and a checker infers variance from where each parameter appears: return positions only means covariant, parameter positions only means contravariant, both means invariant.
solid answer
~40 sSince Python 3.12 (PEP 695) a generic protocol is written with inline type parameters -- `class Sink[T](Protocol): def send(self, item: T) -> None: ...` -- and the checker **infers** each parameter's variance from how it is used inside the body. A parameter appearing only in return positions is covariant, so `Source[str]` is usable where `Source[object]` is wanted; one appearing only in parameter positions is contravariant, giving the opposite direction; one appearing in both, including in a read-write data member, is invariant. The older form still works and is what you meet in existing code: an explicit `typing.TypeVar("T_co", covariant=True)` plus `Protocol[T_co]`, where you declare variance yourself and a checker errors if the positions contradict the declaration. Conformance stays structural: a class with a compatible `send` satisfies `Sink[str]` without inheriting anything.
code
python · 16 linesfrom abc import ABC, abstractmethod
class InventoryFeed(ABC):
@abstractmethod
def fetch(self, since: int) -> list[int]: ...
class LegacyFeed:
def fetch(self, since: int) -> list[int]:
return [since + 1]
InventoryFeed.register(LegacyFeed)
feed = LegacyFeed()
print(isinstance(feed, InventoryFeed))go deeper
Recognise that a protocol can take a type parameter, so one declaration covers a family of shapes, and that annotating a use site means naming the concrete instantiation such as Sink[str].
Explain the inline PEP 695 declaration added in 3.12 alongside the older explicit TypeVar form, and state the positional rule: return-only is covariant, parameter-only is contravariant, both is invariant.
Diagnose variance complaints from a checker and reshape the port to fix them -- splitting a read-write protocol into producer and consumer halves rather than silencing the error with casts.
Own the migration and convention question: whether the codebase standardises on inline type parameters, how much variance sophistication is reasonable to require of contributors, and where invariance is costing real flexibility.
## Declaring the type parameter A generic protocol combines two ideas: the member list a structural interface declares, and a type parameter that stands for whatever payload the implementation handles. Since Python 3.12 the type parameter is written inline with the class, using PEP 695 syntax: ```python from typing import Protocol class Sink[T](Protocol): def send(self, item: T) -> None: ... ``` A consumer then annotates the concrete instantiation it needs -- `def drain(sink: Sink[str], ...)` -- and any class with a compatible `send(self, item: str) -> None` conforms, having inherited nothing. The generic machinery changes none of the structural rules; it just lets one protocol describe a family of shapes instead of one. The pre-3.12 spelling is still valid on 3.14 and is what most existing code looks like: ```python from typing import Protocol, TypeVar T_co = TypeVar("T_co", covariant=True) class Source(Protocol[T_co]): def get(self) -> T_co: ... ``` Here the type variable is a module-level object with variance declared as a keyword argument, and the naming convention `T_co` / `T_contra` exists precisely because that declaration is otherwise invisible at the use site. ## What variance actually asks Variance answers one question: given `Sink[str]`, is it acceptable where `Sink[object]` is required, or the other way round, or neither? The answer follows from where the parameter appears in the protocol's members. If `T` appears **only in return positions**, the protocol only ever hands values out. A `Source[str]` hands out strings, and anything expecting to receive objects is satisfied by receiving strings, so `Source[str]` is usable as `Source[object]`. That is **covariance**, and it is the shape of read-only, producer-like protocols. If `T` appears **only in parameter positions**, the protocol only ever consumes values. A `Sink[object]` accepts anything, so it can stand in wherever a `Sink[str]` is required -- the substitution runs the opposite way. That is **contravariance**, the shape of consumer-like protocols. If `T` appears in **both**, the protocol both accepts and produces, and neither substitution is safe: the protocol is **invariant**. A read-write data member, `items: list[T]`, forces invariance on its own, because it can be read and assigned. ## Inference versus declaration The important change PEP 695 brought in 3.12 is that you no longer state variance -- the checker infers it from the body, per parameter, and keeps it correct as the protocol evolves. Under the old syntax you declared it and the checker verified the declaration, so a `TypeVar` created without `covariant=True` but used only as a return type produced a diagnostic telling you a covariant variable was expected. That error is one of the more confusing things a newcomer to typed Python meets, and knowing it is a variance-position complaint rather than a bug in the protocol is most of the fix. The two styles do not mix within one class: a class either uses the inline parameter list or the `Protocol[T_co]` base form. ## Practical consequences Keeping a protocol producer-only or consumer-only is a design lever, not pedantry. A protocol that both takes and returns its parameter is invariant, and invariance is what makes callers write awkward casts or duplicate functions per concrete type. Splitting a read-write port into a narrow producer protocol and a narrow consumer protocol usually removes the friction, and it matches the advice to declare the weakest member that works. Two smaller points come up in interviews. Bounds and constraints work as they do for any generic: `class Sink[T: (str, bytes)](Protocol)` constrains the parameter, and a bound narrows it. And generics compose with the callable form -- a protocol may declare a generic `__call__`, describing a family of callbacks parameterised by their payload -- which is a shape no `Callable` annotation can express, since it would need to bind the type variable at the call rather than at the annotation. ## What stays out of scope All of this is a static-checking story. At runtime a generic protocol is an ordinary class object; `Sink[str]` produces a subscripted alias, the type parameter is erased for execution purposes, and no verification of any kind happens when a value is passed. Variance never affects program behaviour -- it only decides which assignments a checker permits.
- A protocol declares `items: list[T]` as a data member. What variance does that force, and why?Invariance. A bare data annotation is read-write, so consumers may both read a `T` out of it and assign a `T` into it. Reading alone would allow covariance and writing alone contravariance, but permitting both makes either substitution unsafe. Splitting the port into a read-only producer protocol and a write-only consumer protocol is the usual way to recover the flexibility.
- Under the older TypeVar syntax, what does a checker error about a covariant type variable being expected mean?It means the type variable appears only in return positions inside the protocol, so its usage is covariant, but it was created without `covariant=True`. The fix is to declare it as covariant -- conventionally named `T_co` -- or to change the member signatures if the invariance was intended. On 3.12 and later the inline PEP 695 parameter list removes the class of error entirely by inferring variance.
- Does variance change anything at runtime?No. Variance is purely a static-checking rule about which assignments and arguments are permitted. At runtime a generic protocol is an ordinary class, subscripting it yields an alias object, and no conformance or type-parameter checking happens when a value is passed. A program with every variance annotation deleted behaves identically.
saying these in an interview costs you the question
- Thinks variance is enforced or checked at runtime
- Declares every type variable invariant and fights the checker
- Believes a generic protocol must be subclassed to conform
- Cannot connect variance to return versus parameter positions
- Assumes PEP 695 syntax exists before Python 3.12
- Mixes inline type parameters with the Protocol[T] base form