Skip to content

← Structural Typing and the Hard Parts step 2 of 24

Hard Primitives

The protocol attribute invariance trap

This is the single most common reason a reviewer rejects a Protocol, and the two usual “fixes” are both wrong: widening the implementation until it matches, or reaching for cast.

The rule

A protocol member declared as a plain attribute is readable and writable. A caller holding a HasPayload is allowed to do envelope.payload = {}. For that to be sound, the attribute type must be invariant — the implementation’s type must match exactly, not merely be a subtype.

So this fails:

class HasPayload(Protocol):
    payload: Mapping[str, object]      # mutable member -> invariant

@dataclass(frozen=True)
class JsonEnvelope:
    payload: dict[str, str]            # a *subtype*, and therefore rejected

The reasoning is the same one that makes list[Dog] not a list[Animal]. If JsonEnvelope were accepted, a function taking HasPayload could assign a dict[str, int] into a field the rest of the program believes holds dict[str, str].

The fix

Declare the member read-only, as a property:

class HasPayload(Protocol):
    @property
    def payload(self) -> Mapping[str, object]: ...

A read-only member is covariant, so every implementation whose payload type is a subtype now satisfies it. Crucially, a plain attribute satisfies a read-only property member — the implementation does not have to change at all, and does not have to become a property. (The reverse is not true: a read-only property does not satisfy a mutable attribute member.)

Your task

The file you are given already works at runtime and already has three implementations you must not touch:

  • JsonEnvelope — supplies the narrower dict[str, str]
  • FrozenEnvelope — supplies exactly Mapping[str, object]
  • LazyEnvelope — supplies the member as a real @property

Change only the protocol so all three type-check.

def solve(mutable: list[dict[str, str]], readonly: list[dict[str, str]]) -> list[str]:

Build one JsonEnvelope per entry of mutable, then one FrozenEnvelope and one LazyEnvelope per entry of readonly (in that order), and return describe(...) of each. describe renders "<count>:<sorted keys joined by commas>".

The trade you are making

Making a member read-only is a real narrowing of the interface: consumers of HasPayload can no longer assign to payload, and mypy will tell them so. That is the point. If a consumer genuinely needs to write through the protocol, the invariance is correct and the implementations must widen — but that is a decision you make deliberately, not one you discover when the checker complains.

Loading visualization…