Whetstone.
Types, HonestlyYour interface, spelled three different ways
Module 3 · Lesson 215 min

Your interface, spelled three different ways

In TypeScript you write interface and stop thinking. Python has three answers, they are genuinely different at runtime, and picking the wrong one is the most common structural mistake a TypeScript developer makes in their first month here.

TypedDict: your interface, and it stays a dict

Shape only, no class
from typing import TypedDict, NotRequired

class AgentState(TypedDict):
    messages: list[str]
    step: int
    scratch: NotRequired[str]     # may be absent entirely

At runtime, this creates nothing. A value annotated AgentState is a plain dict. There is no wrapper, no validation, no constructor logic, and no error if you put a key in that the declaration does not name. It is annotation-only, exactly like a TypeScript interface: real to the checker, absent from the output.

That sounds useless until you notice what it buys: the value is still a dict, so it serialises, persists, merges, and crosses a process boundary without ceremony.

Which is precisely why LangGraph state is a TypedDict. State gets checkpointed, written to Postgres, resumed in another process. A dict does all that natively and an object does not.

What a LangGraph state actually looks like
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]
    remaining_steps: int

Annotated[T, extra] is worth pausing on. It attaches metadata to a type without changing the type: to a checker this is still list, and the second argument is a payload that a library reads. LangGraph reads it to find the reducer that merges concurrent writes to that key. You will meet Annotated constantly in this ecosystem, and it is always this pattern: a type, plus something the framework picks up.

NotRequired and Required mark individual keys (3.11 and later). total=False in the class header makes every key optional at once, which is the older and blunter form.

dataclass: a real object, with the boilerplate generated

The workhorse
from dataclasses import dataclass, field

@dataclass
class RetryPolicy:
    attempts: int = 3
    backoff: float = 1.5
    tags: list[str] = field(default_factory=list)

p = RetryPolicy(attempts=5)
p.attempts                      # attribute access, not p["attempts"]
p == RetryPolicy(attempts=5)    # True. __eq__ is generated.
repr(p)                         # 'RetryPolicy(attempts=5, backoff=1.5, tags=[])'

The decorator reads the annotated class attributes and generates __init__, __repr__ and __eq__. That is the whole feature. It is the closest thing Python has to a plain data class, it is in the standard library, and it is the right default for objects that live inside your program.

The useful options:

The four you will actually use
@dataclass(frozen=True)    # immutable. Also makes instances hashable.
@dataclass(slots=True)     # no __dict__. Less memory, faster, no ad-hoc attributes.
@dataclass(kw_only=True)   # every field must be passed by keyword
@dataclass(order=True)     # generates __lt__ and friends, so instances sort

frozen=True is worth reaching for by default on anything that represents a value rather than an entity. slots=True incidentally fixes the typo hazard from module 1: with slots, p.atempts = 9 raises AttributeError instead of silently creating a field.

Dataclasses do not validate. RetryPolicy(attempts="five") constructs happily, because the annotation is a hint and the generated __init__ just assigns.

Protocol: structural typing, arriving late and arriving right

Describing what you need rather than what something is
from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def cleanup(resource: SupportsClose) -> None:
    resource.close()

Any class with a matching close method satisfies SupportsClose. It does not import it, subclass it, or know it exists. This is TypeScript’s interface semantics, and it is the closest thing in Python to how you already think.

Two things this makes possible that nominal typing cannot:

  • Describing a type you do not own. Write a Protocol matching a third-party class and your code is typed against the shape rather than the vendor.
  • Testing without mocking frameworks. A five-line stub class satisfies the protocol and your production code cannot tell.

@runtime_checkable goes on the protocol and enables isinstance checks against it. Use it sparingly: it checks method names only, not signatures, so it can say yes to something that will explode on call.

Choosing

Question Answer
Does it get serialised, persisted, or sent as JSON? TypedDict
Is it an object in memory that your own code owns? dataclass
Are you describing what you need from a collaborator? Protocol
Did the data come from outside the program? Pydantic, next lesson

The rough map: TypedDict for data on the wire, dataclass for objects in memory, Protocol for the interface between them.

The pattern you will actually meet

Real agent code uses all three within a single file, and once you can see why, the file stops looking arbitrary:

Three shapes, three jobs
class GraphState(TypedDict):              # persisted by a checkpointer
    messages: Annotated[list, add_messages]

@dataclass(frozen=True)
class Context:                            # in-memory, passed at invoke time
    user_id: str
    locale: str = "en"

class Store(Protocol):                    # whatever backend we were handed
    def get(self, key: str) -> dict | None: ...
    def put(self, key: str, value: dict) -> None: ...

State is a dict because it crosses a boundary. Context is a frozen dataclass because it is an in-process value nobody should mutate. Store is a protocol because the code does not care what the backend is.

Where this goes

None of these three validate anything. The next lesson is the one that does, and it is the single most important Python library for the work you are about to do.

Practice

Try it yourself

Quiz

What a TypedDict is at runtime

You define class State(TypedDict): messages: list[str] and then build one with s = {"messages": []}. What is s at runtime?

  1. AAn instance of State, which behaves like a dict but rejects keys the declaration does not name
  2. BAn instance of State, a generated class that subclasses dict and validates on construction
  3. CA plain dict until it is passed somewhere annotated State, at which point it is checked and wrapped
  4. DA plain dict, since TypedDict describes the shape of a dict and creates no new class
Show answer

Correct answer: D — A plain dict, since TypedDict describes the shape of a dict and creates no new class

TypedDict is annotation-only. It exists so a checker can describe the shape of a dictionary, and at runtime State(...) returns a plain dict with no validation, no wrapper and no extra behaviour. This is the exact analogue of a TypeScript interface: real to the checker, absent from the output. It is also why LangGraph uses it for state, since the state genuinely needs to be a plain serialisable dict that a checkpointer can persist, and why a wrong key produces no runtime error at all.

Quiz

A dataclass field that is a list

You write a dataclass with a field tags: list[str] = []. What happens?

  1. AIt raises ValueError at class definition time, telling you to use field(default_factory=list)
  2. BIt works, and every instance created without an explicit value shares one list, exactly like a mutable default argument
  3. CIt works, and each instance gets a fresh list, because the decorator rewrites mutable defaults into factories
  4. DIt raises TypeError the first time you instantiate the class without supplying the field
Show answer

Correct answer: A — It raises ValueError at class definition time, telling you to use field(default_factory=list)

This is the one place Python actively protects you from the mutable-default trap: @dataclass inspects its defaults and refuses to build the class, naming default_factory in the message. The shared-list option is what the equivalent plain function default would do and is the reason people expect the shared-list behaviour here too. Knowing that the dataclass case is a loud error and the function-argument case is a silent bug is worth more than either fact alone, because it tells you the language is inconsistent here rather than that you misremembered.

Quiz

Conforming to a Protocol

A library declares class Closeable(Protocol): def close(self) -> None: ... and a function takes a Closeable. What must your class do to be accepted?

  1. ASubclass Closeable explicitly, since a Protocol is an abstract base class with a different name
  2. BDefine a matching close method. It does not import, subclass or reference Closeable in any way
  3. CRegister with Closeable.register(MyClass), which records the conformance for the checker
  4. DDefine a matching close method and add @runtime_checkable, which is what makes conformance count
Show answer

Correct answer: B — Define a matching close method. It does not import, subclass or reference Closeable in any way

A Protocol is structural, so having the right method IS the conformance and there is nothing to declare. That is TypeScript's interface semantics arriving in Python, and it is why a Protocol can retroactively describe a type you do not own. Explicit subclassing is legal and occasionally useful to get an error early, but it is never required. @runtime_checkable is real and does something different: it goes on the PROTOCOL, not on your class, and it only enables isinstance checks, which test method NAMES and not signatures. Never a conformance requirement.

Recall

Choosing between the three

The decision you will make repeatedly in your first month. Get the rule down to one sentence each.

You need a structured shape. When do you reach for TypedDict, when for a dataclass, and when for a Protocol?

Reveal answer

TypedDict when the thing genuinely needs to BE a dict at runtime, because something serialises it, persists it, or hands it to an API that expects JSON. LangGraph state is the standard example. A dataclass when you want a real object with attribute access, generated __init__, __repr__ and __eq__, which is the default choice for internal domain objects. A Protocol when you are describing what you need from something rather than defining a thing, so a function can accept any object with the right shape without that object knowing your protocol exists. The rough map is TypedDict for data on the wire, dataclass for objects in memory, Protocol for the interface between them.

Check

Convert one interface three ways

Take any small TypeScript interface you have written recently and write all three Python spellings of it. Then decide which one it should actually be.

You should see

You can name what changed at runtime in each version, and your justification for the final choice mentions serialisation, identity, or ownership rather than personal taste.

Sign in to track your progress →