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
from typing import TypedDict, NotRequired
class AgentState(TypedDict):
messages: list[str]
step: int
scratch: NotRequired[str] # may be absent entirelyAt 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.
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages]
remaining_steps: intAnnotated[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
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:
@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 sortfrozen=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
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:
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.
Try it yourself
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?
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.
A dataclass field that is a list
You write a dataclass with a field tags: list[str] = []. What happens?
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.
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?
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.
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.
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 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.