skip to content

How do you declare method and attribute members inside a typing.Protocol body?

level: middleimportance: must knowfreq 50%

answer

  1. Signatures, not bodies
  2. Bare annotation for a data member
  3. Plain annotation means writable
  4. Property in the protocol means read-only
  5. ClassVar for a class-level constant

basics

~20 s

Write methods as signatures with an ellipsis body, and data members as bare annotations. A plain annotation means a read-write instance attribute; declare a read-only member with a property in the protocol body, and a class-level constant with ClassVar.

solid answer

~50 s

Methods are written as ordinary `def` signatures whose body is `...`; the body is never executed, so the signature is the whole contract. Data members are bare annotations in the class body -- `doc_id: str` -- and this is the detail interviewers probe: a plain annotation declares a **read-write** instance attribute, so an implementation that exposes `doc_id` only through a read-only `property` is rejected by a checker. To declare a read-only member, put a `@property` in the protocol itself; to declare a class-level constant, annotate it with `typing.ClassVar`. A member given a real body instead of `...` becomes a default implementation, inherited by classes that list the protocol as a base. None of these annotations create runtime attributes -- implementations still assign them, normally in `__init__` -- and protocol members are not abstract unless also decorated with `abc.abstractmethod`.

code

python · 22 lines
python
from typing import Protocol, runtime_checkable


@runtime_checkable
class InventoryFeed(Protocol):
    def fetch(self, since: int) -> list[int]: ...


class WarehouseFeed:
    def fetch(self, since: int) -> list[int]:
        return [since + 1, since + 2]


class LegacyFeed:
    def fetch(self) -> list[int]:
        return [1, 2]


for feed in (WarehouseFeed(), LegacyFeed()):
    print(type(feed).__name__, isinstance(feed, InventoryFeed))

LegacyFeed().fetch(7)

go deeper

for a junior

Recall the two shapes: a method is a def whose body is ..., and a data member is a bare annotation like doc_id: str. Know that the protocol supplies no implementation or state of its own.

for a middle

Explain the read-write meaning of a bare annotation and why a read-only property implementation is rejected, when to reach for @property or typing.ClassVar, and that ... members are not abstract at runtime.

for a senior

Demonstrate judgement about protocol size and member strength -- declare the weakest member that works, keep the surface to what the call site touches, and use bodied members as opt-in defaults rather than requirements.

for a principal

Own the convention: whether protocols in the codebase may carry default implementations at all, how wide a port may get before it should be split, and how much the checker is trusted given none of this is enforced at runtime.

## The two kinds of member A protocol body declares what a conforming value must offer. There are two shapes of declaration and they behave differently, which is where most of the interview value in this topic sits. **Method members** are written as a normal `def` with `...` as the body: ```python from typing import Protocol class Converter(Protocol): def convert(self, doc: str) -> bytes: ... ``` The body is never called during checking and is irrelevant to conformance -- the signature is the contract. The checker compares parameter kinds (positional-only, keyword-only, `*args`, `**kwargs`), parameter names for anything callable by keyword, defaults, and the return type. An implementation may widen a parameter type and narrow a return type; it may not rename a keyword parameter, because callers of the protocol are free to pass it by name. **Data members** are bare annotations: ```python class Job(Protocol): doc_id: str retries: int ``` This is a stronger claim than it looks. A bare annotation declares a mutable instance attribute, so consumers of `Job` are entitled to *write* `job.doc_id = "..."`. As a result an implementation that exposes `doc_id` through a read-only `property` does **not** conform, and the checker says so. This surprises people who think of the annotation as "must have an attribute called `doc_id`". It also means data members are invariant: the implementation's type must match exactly, not merely be a subtype, because both reading and writing are allowed. ## Declaring read-only and class-level members To declare a member that consumers may only read, declare it as a property in the protocol: ```python class Job(Protocol): doc_id: str # read-write @property def attempts(self) -> int: ... # read-only ``` Now an implementation may satisfy `attempts` with a property, a cached computation, or a plain instance attribute -- a plain attribute is readable, so it is compatible with a read-only requirement, while the reverse is not true. This asymmetry is the practical rule: **declare the weakest thing you need**, because a read-only declaration accepts strictly more implementations than a read-write one. For something that lives on the class rather than the instance, annotate with `typing.ClassVar`: ```python from typing import ClassVar, Protocol class Converter(Protocol): fmt: ClassVar[str] def convert(self, doc: str) -> bytes: ... ``` A `ClassVar` member is satisfied by a class-level attribute and may not be assigned per instance. ## Bodies, defaults and abstractness A protocol member does not have to be `...`. Give it a real body and it becomes a **default implementation**: classes that list the protocol as an explicit base class inherit it, while classes that merely match structurally do not get it -- they were never related to the protocol at runtime. This makes a protocol with one or two convenience methods a reasonable mixin for implementations that opt in, without closing the door on implementations that do not. Two runtime facts matter here. First, protocol members written as `...` are **not** abstract: unless the member is also decorated with `abc.abstractmethod`, a concrete subclass that forgets to implement it still instantiates, and calling that member returns `None`. Only a static checker catches the omission. Second, the annotations in the body create no attributes at all. `doc_id: str` adds an entry to the class's annotations and nothing else; implementations must actually assign the attribute, normally in `__init__`. A protocol is never a source of state. ## Annotations on 3.14 Since Python 3.14 (PEP 649/749) annotations are evaluated lazily rather than at class-creation time, and this applies inside protocol bodies like anywhere else. A member may reference a class defined later in the file without quoting it, and the annotation resolves when something asks for it -- `typing.get_type_hints`, or `annotationlib.get_annotations` for finer control over whether you want the evaluated object, a string, or a forward-reference form. On earlier versions the same code needed a string literal or a `from __future__ import annotations` at the top of the module. ## Keeping protocols small Because every declared member is a demand on every implementation, protocol size is a design decision, not a documentation exercise. List what the consumer actually touches. A protocol with eight members, six of which one call site uses, is the structural-typing equivalent of a fat interface -- it rejects perfectly usable objects for no benefit and pushes implementers toward writing stubs that exist only to satisfy the checker.

  • Why does an implementation whose attribute is a read-only property fail a protocol that declares it as a bare annotation?
    Because a bare annotation declares a read-write instance attribute, so consumers of the protocol are entitled to assign to it. A read-only property cannot honour that, and the checker rejects it. Declaring the member as a `@property` in the protocol asks only for readability, which both a property and a plain attribute satisfy -- so the read-only declaration is the more permissive one.
  • If a protocol member has a real body rather than an ellipsis, who gets it?
    Only classes that list the protocol as an explicit base class inherit it as a default implementation. A class that conforms structurally has no runtime relationship with the protocol at all, so it gets nothing -- it must define the member itself. This makes bodied members useful as an opt-in mixin without making them part of the required shape.
  • Does declaring `retries: int` in a protocol create that attribute anywhere?
    No. It records an annotation on the protocol class and nothing else -- no descriptor, no default value, no class attribute. Every implementation must assign the attribute itself, typically in `__init__`. Protocols describe shape; they never supply state.

saying these in an interview costs you the question

  • Thinks the ellipsis body is executed or matters to conformance
  • Assumes a read-only property satisfies a bare data annotation
  • Believes annotations in the protocol create real attributes
  • Says protocol members are abstract and block instantiation
  • Uses a bare annotation where ClassVar is meant
  • Adds every plausible member instead of what the consumer calls

context