Skip to content

← Structural Typing and the Hard Parts step 13 of 24

Medium Primitives

TypedDict: Required, NotRequired and the ReadOnly unlock

TypedDict is how you type the JSON crossing every service boundary. Get requiredness wrong and you either ship a KeyError or you defend with .get() on keys that are always present, which is the same thing as having no types.

Requiredness

Per-item Required[...] / NotRequired[...] (3.11) is strictly better than the class-level total=False: it is local (you read it on the line that declares the item) and composable (a class can mix both). Prefer it.

Why plain TypedDicts barely compose

Every item of an ordinary TypedDict is mutable, and a mutable item is invariant — the exact same rule as protocol attributes. So a “view” type declared

class EventLike(TypedDict):
    name: str
    seq: int

rejects a concrete event whose name is Literal["ping"], because Literal["ping"] is a subtype of str, not str itself. In practice teams gave up and typed the seam dict[str, Any].

ReadOnly (3.13)

ReadOnly[...] marks an item as not writable through this type, and a read-only item is covariant. Two consequences, both exercised here:

  • a source may supply a narrower value type (Literal["ping"] for ReadOnly[str]);
  • a source may supply a Required item where the view asks only for NotRequired — a required key is a perfectly good non-required one, as long as nobody can del it through the view.

Do not over-read the second point: mypy 2.3 still requires the source to declare the key. A view item that the source omits entirely is rejected regardless of ReadOnly.

Your task

Rewrite EventLike as a read-only view so that all three concrete shapes are assignable to list[EventLike]:

  • ClickEventname: str, seq: int, plus x/y, and a NotRequired[str] detail it never sets
  • ErrorEventdetail: str required, plus a severity the view never mentions
  • PingEventname: Literal["ping"], and a NotRequired[Literal["pong"]] detail

summarise(event: EventLike) -> str renders f"{event['name']}#{event['seq']}({event.get('detail', '-')})".

def solve(seqs: list[int], details: list[str]) -> list[str]:

For each (seq, detail) pair build one of each event (click has no detail; error’s detail is the input; ping’s detail is "pong"), put them in list[EventLike], and summarise each in that order.

Two things the checker will not tell you

Inside summarise, assigning to event["name"] is a static error — that is the guarantee ReadOnly buys, and it is why the view is safe to hand out.

And a verified divergence worth carrying: mypy --strict does not flag reading a NotRequired key with []. Pyright reports reportTypedDictNotRequiredAccess at basic, standard and strict. If mypy is your only checker, event["detail"] on a not-required item is a KeyError waiting to happen; use .get().