TypedDict, Annotated And Graph State
This page exists to make one line readable:
class State(TypedDict):
messages: Annotated[list, add_messages]That line sits at the top of nearly every LangGraph program. Most people copy it, it works, and they never find out what it says — until their agent starts forgetting things and they have no idea where to look.
It is three separate ideas stacked together. We will take them one at a time.
Idea 1: TypedDict is a dictionary with the keys written down
You already know dictionaries:
state = {"question": "why is the server down?", "attempts": 0}
print(state["question"])A TypedDict is the same dictionary, with a note saying which keys it has:
from typing import TypedDict
class State(TypedDict):
question: str
answer: str
attempts: int
state: State = {"question": "why is the server down?", "answer": "", "attempts": 0}
print(state["question"])
print(type(state))why is the server down?
<class 'dict'>Look at that last line. It is still a plain dictionary.
The class keyword is misleading. You are not making an object. You create it with {} and read it with state["question"] — not state.question. That mistake catches everyone once.
So why bother?
Because your editor can now help you:
- it autocompletes the key names
- it warns you when you type
state["anser"] - anyone opening the file learns the whole shape of the state in five lines
In a graph, state passes through every node. Writing the shape down once is the difference between a program you can change in three months and one you cannot.
It does not check anything at runtime
sloppy: State = {"question": 123, "attempts": "three"}
print(sloppy){'question': 123, 'attempts': 'three'}No error. TypedDict is a note for tools, not a runtime guard. If you want real validation, that is Pydantic's job — and the two are used for different things:
TypedDict | BaseModel (Pydantic) | |
|---|---|---|
| What you get | a plain dict | an object |
| Access | state["key"] | obj.key |
| Checked at runtime | no | yes |
| Cost | zero | small |
| Used for | graph state | tool inputs, model output |
The rule: TypedDict for state moving between your own nodes. Pydantic for anything arriving from a model. State is yours and is written many times per run, so keep it cheap. Tool arguments cannot be trusted, so validate them.
Idea 2: Annotated attaches a sticky note to a type
from typing import Annotated
x: Annotated[int, "Python completely ignores this part"]Annotated[TYPE, EXTRA] means: the type is TYPE, and here is some EXTRA information for whoever is interested.
Python does nothing with the extra part. It is a place for a library to leave a message for itself.
On its own this looks pointless. In LangGraph it is the mechanism that makes the whole graph work.
Idea 3: the extra part is a merge instruction
Here is the problem LangGraph has to solve.
A node does not return the whole state. It returns only what it changed:
def triage(state):
return {"messages": ["I checked the logs"]}LangGraph now has to combine that with the existing state. And there is a genuine question: what should it do with the messages already there?
Replace them, or add to them?
The default is replace
class State(TypedDict):
messages: listWatch what that means, simulated by hand:
state = {"messages": []}
for turn in ["hello", "how are you", "goodbye"]:
update = {"messages": [turn]}
state["messages"] = update["messages"] # replace
print(state["messages"])['goodbye']Your entire conversation history is one message long. Forever.
This is the single most common LangGraph bug, and it raises no error at all. The agent simply appears to have no memory.
add_messages says "append instead"
class State(TypedDict):
messages: Annotated[list, add_messages]Same simulation, with appending:
state = {"messages": []}
for turn in ["hello", "how are you", "goodbye"]:
update = {"messages": [turn]}
state["messages"] = state["messages"] + update["messages"] # append
print(state["messages"])['hello', 'how are you', 'goodbye']That is it. The difference between an agent that remembers and one that forgets is one word inside square brackets.
The function in that second slot is called a reducer.
Now read the original line
class State(TypedDict):
messages: Annotated[list, add_messages]Three pieces, left to right:
| Piece | Means |
|---|---|
messages | the key in the state dictionary |
list | the value is a list |
add_messages | how to combine a new value with the old one |
Say it as a sentence: "messages is a list, and when a node returns new messages, append them rather than replacing."
Writing your own reducer
A reducer is just a function taking the old value and the new one:
def keep_highest(old: int, new: int) -> int:
return max(old, new)
class State(TypedDict):
risk_score: Annotated[int, keep_highest]Two arguments in, one value out. If three branches each report a risk score, the state keeps the worst. That is the entire contract.
A state with a mixture
import operator
from typing import Annotated, TypedDict
class State(TypedDict):
messages: Annotated[list, add_messages]
visited: Annotated[list, operator.add]
risk_score: Annotated[int, keep_highest]
attempts: int
answer: str| Field | Behaviour |
|---|---|
messages | appended, with message-aware handling |
visited | joined, because operator.add on two lists concatenates |
risk_score | the highest wins |
attempts | replaced by the latest |
answer | replaced by the latest |
Replace is the default and is often right. Reach for a reducer when a field should accumulate — history, logs, results gathered from branches running in parallel.
How a node uses the state
def triage(state: State) -> dict:
question = state["question"]
return {"answer": "restart the service", "attempts": state["attempts"] + 1}Two habits worth forming now:
- read with
state["key"]— it is a dictionary - return only what you changed — LangGraph merges your partial update using the reducers
Returning the whole state works, but throws away the benefit of reducers and makes parallel branches overwrite each other.
Coming from Java or C#
There is no clean equivalent, which is exactly why this trips people up.
The closest picture is a Map<String, Object> where someone wrote an interface listing the keys — except the interface is checked by your editor rather than the compiler, and at runtime the thing really is just a map.
Annotated has no counterpart at all. The nearest is an annotation carrying a strategy class that a framework reads later, and that is a stretch.
Common mistakes
- Forgetting the reducer on
messages. Silent memory loss, no error. Check this first when an agent forgets. - Using dot access.
state.messagesfails. It is a dictionary. - Returning the whole state from a node instead of just the changed keys.
- Expecting validation. Wrong types go in silently and break somewhere else later.
- A reducer on a field that should be replaced, so an
attemptscounter quietly turns into a list.
Practise this
- Run both simulations above — replace and append — and see the difference in output.
- Write a
Statewith a question, an answer, and avisitedlist that accumulates. - Write a node that reads one key and returns an update to another.
- Write a reducer that keeps the longest string it has seen.
- In a working graph, change
Annotated[list, add_messages]to plainlist, watch the memory vanish, then change it back.