Python That Agent Frameworks Assume

TypedDict & Annotated

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:

TypedDictBaseModel (Pydantic)
What you geta plain dictan object
Accessstate["key"]obj.key
Checked at runtimenoyes
Costzerosmall
Used forgraph statetool 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: list

Watch 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:

PieceMeans
messagesthe key in the state dictionary
listthe value is a list
add_messageshow 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
FieldBehaviour
messagesappended, with message-aware handling
visitedjoined, because operator.add on two lists concatenates
risk_scorethe highest wins
attemptsreplaced by the latest
answerreplaced 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.messages fails. 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 attempts counter quietly turns into a list.

Practise this

  • Run both simulations above — replace and append — and see the difference in output.
  • Write a State with a question, an answer, and a visited list 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 plain list, watch the memory vanish, then change it back.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1# The line at the top of every LangGraph program, taken apart.
2# class State(TypedDict):
3# messages: Annotated[list, add_messages]
4
5import operator
6from typing import Annotated, TypedDict
7
8
9# A TypedDict is a dictionary with a written-down shape.
10class State(TypedDict):
11 question: str
12 answer: str
13 attempts: int
14
15
16state: State = {"question": "why is the server down?", "answer": "", "attempts": 0}
17print(state["question"]) # dictionary access, NOT state.question
18print(type(state)) # it really is just a dict
19print()
20
21# Nothing is checked at runtime - this is legal and nothing complains:
22sloppy: State = {"question": 123, "attempts": "three"}
23print("no validation happened:", sloppy)
24print()
25
26
27# --- Annotated attaches a note to a type ---
28# Python ignores the second part. Libraries read it.
29x: Annotated[int, "python ignores this"]
30
31
32# --- A reducer decides how updates are COMBINED ---
33def add_messages(old, new):
34 """A simplified version of LangGraph's real reducer."""
35 return old + new
36
37
38def keep_highest(old: int, new: int) -> int:
39 return max(old, new)
40
41
42class GraphState(TypedDict):
43 messages: Annotated[list, add_messages] # appended
44 visited: Annotated[list, operator.add] # concatenated
45 risk_score: Annotated[int, keep_highest] # highest wins
46 answer: str # replaced (the default)
47
48
49# Merging by hand, exactly as a graph would:
50REDUCERS = {
51 "messages": add_messages,
52 "visited": operator.add,
53 "risk_score": keep_highest,
54}
55
56
57def merge(state: dict, update: dict) -> dict:
58 result = dict(state)
59 for key, value in update.items():
60 reducer = REDUCERS.get(key)
61 result[key] = reducer(state[key], value) if reducer else value
62 return result
63
64
65s = {"messages": [], "visited": [], "risk_score": 0, "answer": ""}
66
67s = merge(s, {"messages": ["user: server is down"], "visited": ["triage"], "risk_score": 3})
68s = merge(s, {"messages": ["assistant: checking"], "visited": ["lookup"], "risk_score": 7})
69s = merge(s, {"messages": ["assistant: found it"], "visited": ["notify"], "risk_score": 2, "answer": "restart"})
70
71print("messages accumulated :", s["messages"])
72print("visited accumulated :", s["visited"])
73print("risk kept the highest:", s["risk_score"])
74print("answer was replaced :", s["answer"])
75print()
76
77# THE BUG: forget the reducer and `messages` is REPLACED every turn.
78no_reducer = {"messages": []}
79for turn in ["one", "two", "three"]:
80 no_reducer["messages"] = [turn] # what a node returns, merged by replace
81print("without a reducer, memory is lost:", no_reducer["messages"])
82
83# A node returns ONLY the keys it changed - never the whole state.
84def triage(state: GraphState) -> dict:
85 return {"answer": "restart the service", "visited": ["triage"]}