Module 5: Introduction to LangGraph
Typed State with TypedDict and Annotated
Capsule overview
State design is THE most important decision you make when building a graph. Well-designed state makes everything else easy — the nodes are simple, the edges are clear, and debugging is straightforward. Badly designed state produces silent bugs, data that quietly disappears, and nodes that can't communicate properly.
In capsule 02 you saw TypedDict for the first time when you created your first StateGraph. You defined a basic state with messages and used it to pass information between nodes. Now you're going much deeper: you'll understand exactly how Annotated works with reducers, why operator.add is critical for message lists, what happens when you DON'T use it (spoiler: you lose data), and how to design state for different kinds of applications.
Think of state as your graph's architectural blueprint. Just as an architect doesn't start building without plans, you shouldn't write nodes or edges without designing your state first. This capsule teaches you to think like an architect of AI workflows.
TypedDict: the foundation of state
TypedDict is the standard way to define a graph's state in LangGraph. It defines a dictionary with specific keys and types:
from typing import TypedDict
class MyState(TypedDict):
messages: list[str]
current_step: str
is_done: bool
Why TypedDict and not a dataclass? Because LangGraph works internally with dictionaries — every node receives a dict and returns a dict. TypedDict gives you type hints and IDE autocomplete without changing that nature:
state: MyState = {"messages": ["hello"], "step_count": 0}
print(type(state)) # <class 'dict'>
print(state["messages"]) # ['hello']
The problem: silent overwrites
Before understanding reducers, you need to see the bug they solve. This is the most common mistake in LangGraph, and the hardest one to diagnose if you don't know what to look for.
Without reducers — data that disappears
Imagine a graph with two nodes. Both add a message to the state:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class BuggyState(TypedDict):
messages: list[str]
final_answer: str
def node_a(state: BuggyState) -> dict:
return {"messages": ["Hello from node A"]}
def node_b(state: BuggyState) -> dict:
return {"messages": ["Hello from node B"]}
graph_builder = StateGraph(BuggyState)
graph_builder.add_node("a", node_a)
graph_builder.add_node("b", node_b)
graph_builder.add_edge(START, "a")
graph_builder.add_edge("a", "b")
graph_builder.add_edge("b", END)
graph = graph_builder.compile()
result = graph.invoke({"messages": [], "final_answer": ""})
print(result["messages"])
# Output: ['Hello from node B']
# !! "Hello from node A" was LOST !!
Node A wrote ["Hello from node A"] into messages. Then node B wrote ["Hello from node B"]. Without a reducer, LangGraph simply replaces the value — the last node wins, and node A's message disappears.
In a chat application, this would mean that every time a node processes a message, all the previous messages get erased. Your chatbot would have total amnesia.
Annotated and reducers: the solution
What a reducer is
A reducer is a function that tells LangGraph how to combine the value a node returns with the value that already exists in the state. Instead of replacing, the reducer defines the merge logic.
The syntax uses Python's Annotated:
from typing import TypedDict, Annotated
import operator
class MyState(TypedDict):
messages: Annotated[list[str], operator.add] # Reducer: accumulates
current_step: str # No reducer: replaces
Annotated[list[str], operator.add] says: "this field is a list of strings, and when a node returns a new value, concatenate the new list with the existing one instead of replacing it."
operator.add for lists: ACCUMULATES
Now let's fix the bug from the previous example:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class FixedState(TypedDict):
messages: Annotated[list[str], operator.add] # ← Reducer
final_answer: str
def node_a(state: FixedState) -> dict:
return {"messages": ["Hello from node A"]}
def node_b(state: FixedState) -> dict:
return {"messages": ["Hello from node B"]}
graph_builder = StateGraph(FixedState)
graph_builder.add_node("a", node_a)
graph_builder.add_node("b", node_b)
graph_builder.add_edge(START, "a")
graph_builder.add_edge("a", "b")
graph_builder.add_edge("b", END)
graph = graph_builder.compile()
result = graph.invoke({"messages": [], "final_answer": ""})
print(result["messages"])
# Output: ['Hello from node A', 'Hello from node B']
# Both messages preserved!
The only difference is Annotated[list[str], operator.add]. Now:
- The initial state has
messages: [] - Node A returns
{"messages": ["Hello from node A"]}→ LangGraph runs[] + ["Hello from node A"]→["Hello from node A"] - Node B returns
{"messages": ["Hello from node B"]}→ LangGraph runs["Hello from node A"] + ["Hello from node B"]→["Hello from node A", "Hello from node B"]
Without Annotated for scalars: REPLACES
Fields without Annotated use the default behavior: replacement. That's exactly what you want for values representing "the current state" of something:
from typing import TypedDict, Annotated
import operator
class ProcessState(TypedDict):
messages: Annotated[list[str], operator.add] # Accumulates
current_topic: str # Replaces
confidence: float # Replaces
is_complete: bool # Replaces
Here current_topic, confidence, and is_complete get replaced every time a node updates them — which is the right behavior. If the analysis node decides the current topic is "machine learning" with confidence 0.85, you want that value, not a concatenation with previous ones.
The golden rule
- ✅ Lists that accumulate data (messages, sources, results) →
Annotated[list[...], operator.add] - ✅ Scalars that represent the current state (topic, confidence, step) → No Annotated (replaces)
- ❌ Lists without a reducer → Silent bug, data gets lost
- ❌ Scalars with operator.add → Type errors (you can't add strings with
+this way)
Custom reducers
operator.add covers most cases, but sometimes you need custom merge logic. A custom reducer is any function that takes two arguments (the current value and the new one) and returns the combined value.
Example: deduplicating sources
from typing import TypedDict, Annotated
import operator
def deduplicate_sources(current: list[str], new: list[str]) -> list[str]:
seen = set(current)
result = list(current)
for source in new:
if source not in seen:
result.append(source)
seen.add(source)
return result
class ResearchState(TypedDict):
messages: Annotated[list, operator.add]
sources: Annotated[list[str], deduplicate_sources] # No duplicates
current_topic: str
If node A returns {"sources": ["arxiv.org/123", "wiki/AI"]} and node B returns {"sources": ["wiki/AI", "docs.python.org"]}, the result will be ["arxiv.org/123", "wiki/AI", "docs.python.org"].
Example: keeping the highest value
def keep_highest(current: float, new: float) -> float:
return max(current, new)
class AnalysisState(TypedDict):
best_confidence: Annotated[float, keep_highest]
current_step: str
When to write a custom reducer
- ✅ Deduplicating items in a list
- ✅ Applying a maximum limit (e.g., keeping only the last N messages)
- ✅ Merging with business logic (e.g., prioritizing certain values over others)
- ❌ Accumulating simple lists → use
operator.add - ❌ Replacing values → don't use a reducer
MessagesState: the shortcut for chat
LangGraph includes a prebuilt state for chat applications: MessagesState. It already has messages configured with operator.add and uses LangChain's message types:
from langgraph.graph import MessagesState
# MessagesState is equivalent to:
# class MessagesState(TypedDict):
# messages: Annotated[list[AnyMessage], operator.add]
Using MessagesState directly
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, MessagesState, START, END
model = init_chat_model("openai:gpt-4.1-mini")
def chatbot(state: MessagesState) -> dict:
response = model.invoke(state["messages"])
return {"messages": [response]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
graph = graph_builder.compile()
result = graph.invoke({"messages": [HumanMessage(content="What is Python?")]})
print(result["messages"][-1].content)
# Output: Python is a high-level, interpreted programming language...
Extending MessagesState with extra fields
For most real applications, you need more fields beyond messages. You can extend MessagesState:
from typing import Annotated
import operator
from langgraph.graph import MessagesState
class ChatbotState(MessagesState):
user_name: str
conversation_topic: str
sources: Annotated[list[str], operator.add]
This inherits messages with its reducer already configured and adds your custom fields.
When to use MessagesState vs custom state?
- ✅ Use MessagesState when your graph is primarily a chatbot or a conversational system
- ✅ Extend MessagesState when you need extra fields but the core is still conversation
- ✅ Use custom state when your graph processes non-conversational data (e.g., a data pipeline, ETL)
- ❌ Don't force MessagesState onto graphs that aren't chat — design your own state
Designing state for different use cases
State varies by application type. These three patterns cover most scenarios:
from typing import TypedDict, Annotated
import operator
from langchain_core.messages import AnyMessage
# Chat: messages + user context
class ChatState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add] # Accumulates
user_id: str # Replaces
conversation_summary: str # Replaces
# Research: multiple sources that accumulate
class ResearchState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add] # Accumulates
sources: Annotated[list[str], operator.add] # Accumulates
findings: Annotated[list[str], operator.add] # Accumulates
current_topic: str # Replaces
confidence: float # Replaces
is_complete: bool # Replaces
# Multi-step pipeline: data that gets transformed + errors that accumulate
class PipelineState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add] # Accumulates
raw_input: str # Replaces
analysis_results: Annotated[list[dict], operator.add] # Accumulates
errors: Annotated[list[str], operator.add] # Accumulates
current_step: str # Replaces
The pattern is consistent: lists that grow use operator.add, scalars representing "the current thing" get replaced.
Best practices for designing state
What to put in the state
- ✅ Data that needs to flow between nodes — if one node produces something another node consumes, it goes in the state
- ✅ Conversation history — almost always
messageswithoperator.add - ✅ Flow-control flags — like
is_complete,should_continue,current_step - ✅ Intermediate results — if you need to accumulate data from multiple nodes
What NOT to put in the state
- ❌ Static configuration — API keys, model names, constants. Use module variables or config
- ❌ Non-serializable objects — database connections, file handles, HTTP clients
- ❌ Huge data — if a field is going to hold megabytes of data, rethink your design
- ❌ Duplicates — if you can derive a value from other state fields, don't duplicate it
State as the single source of truth
A node should never depend on global variables, temp files, or side effects. Everything a node needs to do its job must be in the state:
# BAD — depends on a global variable
results_cache = []
def search_node(state):
results = do_search(state["current_topic"])
results_cache.extend(results) # Side effect!
return {"current_step": "analyze"}
# GOOD — everything in the state
def search_node(state):
results = do_search(state["current_topic"])
return {
"findings": results,
"current_step": "analyze"
}
Partial updates
Nodes don't need to return the whole state. They return only the fields they want to update:
class MyState(TypedDict):
messages: Annotated[list, operator.add]
current_step: str
confidence: float
is_complete: bool
def analyze_node(state: MyState) -> dict:
# Updates only 2 of 4 fields
return {
"confidence": 0.92,
"current_step": "summarize"
}
# messages and is_complete are left alone
LangGraph applies the update only to the returned fields. Fields not mentioned keep their current value.
Complete example: an analysis pipeline
Let's put it all together in a graph with two nodes that accumulate findings:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, AnyMessage
from langgraph.graph import StateGraph, START, END
class AnalysisState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
findings: Annotated[list[str], operator.add]
current_step: str
confidence: float
model = init_chat_model("openai:gpt-4.1-mini")
def extract_topics(state: AnalysisState) -> dict:
last_message = state["messages"][-1].content
response = model.invoke(
f"Extract the 3 main topics, comma-separated:\n{last_message}"
)
topics = [t.strip() for t in response.content.split(",")]
return {"findings": [f"Topics: {', '.join(topics)}"], "current_step": "sentiment"}
def analyze_sentiment(state: AnalysisState) -> dict:
last_message = state["messages"][-1].content
response = model.invoke(
f"Overall sentiment (positive/negative/neutral):\n{last_message}"
)
return {
"findings": [f"Sentiment: {response.content.strip()}"],
"confidence": 0.85, "current_step": "done"
}
graph_builder = StateGraph(AnalysisState)
graph_builder.add_node("extract", extract_topics)
graph_builder.add_node("sentiment", analyze_sentiment)
graph_builder.add_edge(START, "extract")
graph_builder.add_edge("extract", "sentiment")
graph_builder.add_edge("sentiment", END)
graph = graph_builder.compile()
result = graph.invoke({
"messages": [HumanMessage(content=(
"Python still dominates data science and AI. "
"Its library ecosystem makes it indispensable."
))],
"findings": [], "current_step": "extract", "confidence": 0.0
})
print("Accumulated findings:")
for finding in result["findings"]:
print(f" - {finding}")
print(f"Confidence: {result['confidence']}")
# Output:
# Accumulated findings:
# - Topics: Python, data science, AI
# - Sentiment: positive
# Confidence: 0.85
findings accumulates from both nodes (thanks to operator.add). current_step and confidence get replaced with the latest value. Each node only returns the fields it updates — everything else stays intact.
Troubleshooting
Problem 1: The message list only shows the last value
Symptom: Your messages field only contains what the last node returned, losing everything before it.
Cause: You forgot to add Annotated[..., operator.add] to the field.
Fix:
# BAD
class MyState(TypedDict):
messages: list[str] # No reducer → replaces
# GOOD
class MyState(TypedDict):
messages: Annotated[list[str], operator.add] # With reducer → accumulates
Problem 2: TypeError when using operator.add with strings
Symptom: TypeError: can only concatenate str (not "str") to str or results like "hello" + "world" = "helloworld".
Cause: You used operator.add on a str field instead of a list[str]. The + operator on strings concatenates the characters.
Fix: operator.add is for lists. For scalars you want to replace, don't use a reducer:
# BAD
class MyState(TypedDict):
current_topic: Annotated[str, operator.add] # "topic1" + "topic2" = "topic1topic2"
# GOOD
class MyState(TypedDict):
current_topic: str # Replaces: "topic1" → "topic2"
Problem 3: The node can't read state fields
Symptom: KeyError when accessing a state field inside a node.
Cause: The field wasn't included in the initial state when the graph was invoked.
Fix: Include all the fields in the initial input with default values:
# BAD — 'findings' is missing from the input
result = graph.invoke({"messages": [HumanMessage(content="hello")]})
# GOOD — every field is present
result = graph.invoke({
"messages": [HumanMessage(content="hello")],
"findings": [],
"current_step": "start",
"confidence": 0.0
})
Problem 4: The custom reducer never runs
Symptom: Your custom reducer function is never called; the field behaves as if it had no reducer.
Cause: The function isn't correctly referenced in Annotated, or the node doesn't return that field.
Fix: Check that the function is callable and that the node includes the field in its return:
def my_reducer(current: list, new: list) -> list:
print(f"Reducer called: {current} + {new}") # Debug
return current + new
class MyState(TypedDict):
items: Annotated[list[str], my_reducer]
def my_node(state: MyState) -> dict:
return {"items": ["new_item"]} # Must include 'items' to trigger the reducer
Exercises
Exercise 1: Diagnose the overwrite bug (Easy)
The following graph has a bug: the first node's messages get lost. Find the problem and fix it.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class ChatState(TypedDict):
messages: list[str]
user_name: str
def greet(state):
return {"messages": [f"Hello, {state['user_name']}!"]}
def ask_question(state):
return {"messages": ["How can I help you?"]}
graph_builder = StateGraph(ChatState)
graph_builder.add_node("greet", greet)
graph_builder.add_node("ask", ask_question)
graph_builder.add_edge(START, "greet")
graph_builder.add_edge("greet", "ask")
graph_builder.add_edge("ask", END)
graph = graph_builder.compile()
result = graph.invoke({"messages": [], "user_name": "Carlos"})
print(result["messages"])
# Actual: ['How can I help you?'] — the greeting is missing!
# Expected: ['Hello, Carlos!', 'How can I help you?']
See solution
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class ChatState(TypedDict):
messages: Annotated[list[str], operator.add] # ← Add the reducer
user_name: str
def greet(state):
return {"messages": [f"Hello, {state['user_name']}!"]}
def ask_question(state):
return {"messages": ["How can I help you?"]}
graph_builder = StateGraph(ChatState)
graph_builder.add_node("greet", greet)
graph_builder.add_node("ask", ask_question)
graph_builder.add_edge(START, "greet")
graph_builder.add_edge("greet", "ask")
graph_builder.add_edge("ask", END)
graph = graph_builder.compile()
result = graph.invoke({"messages": [], "user_name": "Carlos"})
print(result["messages"])
# Output: ['Hello, Carlos!', 'How can I help you?']
Explanation: The messages field needs Annotated[list[str], operator.add] so each node's messages accumulate instead of overwriting. Without the reducer, the ask node completely replaces what greet wrote.
Exercise 2: State for a review system (Easy)
Design a TypedDict called ReviewState for a graph that analyzes product reviews. The graph has three nodes: one that extracts keywords, one that determines sentiment, and one that generates a summary. Decide which fields need a reducer and which don't. You don't need to implement the graph — just the state.
See solution
from typing import TypedDict, Annotated
import operator
from langchain_core.messages import AnyMessage
class ReviewState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
keywords: Annotated[list[str], operator.add]
sentiment: str
sentiment_score: float
summary: str
review_text: str
Explanation:
messages→operator.addbecause each node may add debugging or traceability messageskeywords→operator.addbecause the extraction node may run several times, or multiple nodes may contribute keywordssentiment→ No reducer because it's a single value the sentiment node determines (replaces)sentiment_score→ No reducer because it's the current score, not an accumulated onesummary→ No reducer because the summary node generates the final summary (replaces)review_text→ No reducer because it's the original input, which doesn't change
Exercise 3: Custom reducer to cap messages (Medium)
Write a custom reducer that keeps only the last 5 messages in the list. Use it in a state and prove it works by creating a graph where 7 nodes each add a message.
See solution
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
def keep_last_five(current: list[str], new: list[str]) -> list[str]:
"""Accumulate messages but keep only the last 5."""
combined = current + new
return combined[-5:]
class LimitedState(TypedDict):
messages: Annotated[list[str], keep_last_five]
step: int
def make_node(node_id: int):
def node_fn(state: LimitedState) -> dict:
return {
"messages": [f"Message from node {node_id}"],
"step": node_id
}
return node_fn
graph_builder = StateGraph(LimitedState)
for i in range(1, 8):
graph_builder.add_node(f"node_{i}", make_node(i))
graph_builder.add_edge(START, "node_1")
for i in range(1, 7):
graph_builder.add_edge(f"node_{i}", f"node_{i+1}")
graph_builder.add_edge("node_7", END)
graph = graph_builder.compile()
result = graph.invoke({"messages": [], "step": 0})
print(f"Total messages: {len(result['messages'])}")
for msg in result["messages"]:
print(f" - {msg}")
# Output:
# Total messages: 5
# - Message from node 3
# - Message from node 4
# - Message from node 5
# - Message from node 6
# - Message from node 7
Explanation: The keep_last_five reducer concatenates the current list with the new one and then trims to the last 5 items. After 7 nodes, only the messages from nodes 3 through 7 remain. This pattern is useful for keeping the conversation history from growing indefinitely.
Exercise 4: Migrate to MessagesState (Medium)
You have the following custom state. Refactor it so that it extends MessagesState instead of defining messages manually. Verify the behavior is identical.
from typing import TypedDict, Annotated
import operator
from langchain_core.messages import AnyMessage
class MyState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
user_name: str
topic: str
sources: Annotated[list[str], operator.add]
See solution
from dotenv import load_dotenv
load_dotenv()
from typing import Annotated
import operator
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, MessagesState, START, END
class MyState(MessagesState):
user_name: str
topic: str
sources: Annotated[list[str], operator.add]
model = init_chat_model("openai:gpt-4.1-mini")
def greet(state: MyState) -> dict:
return {
"messages": [HumanMessage(content=f"Hi, I'm {state['user_name']}")],
"sources": ["user_input"]
}
def respond(state: MyState) -> dict:
response = model.invoke(state["messages"])
return {
"messages": [response],
"topic": "greeting",
"sources": ["model_response"]
}
graph_builder = StateGraph(MyState)
graph_builder.add_node("greet", greet)
graph_builder.add_node("respond", respond)
graph_builder.add_edge(START, "greet")
graph_builder.add_edge("greet", "respond")
graph_builder.add_edge("respond", END)
graph = graph_builder.compile()
result = graph.invoke({
"messages": [],
"user_name": "Ana",
"topic": "",
"sources": []
})
print(f"Messages: {len(result['messages'])}")
print(f"Sources: {result['sources']}")
print(f"Topic: {result['topic']}")
# Output:
# Messages: 2
# Sources: ['user_input', 'model_response']
# Topic: greeting
Explanation: By extending MessagesState, you inherit messages: Annotated[list[AnyMessage], operator.add] without defining it manually. You only add the extra fields your application needs. The behavior is identical, but the code is cleaner and more standard.
Exercise 5: Complex state with multiple reducers (Advanced)
Design and implement a 3-node graph (search, analyze, summarize) for a research pipeline. The state must use operator.add for sources and findings, a custom reducer for confidence (keeping the highest), and normal replacement for summary.
See solution
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, AnyMessage
from langgraph.graph import StateGraph, START, END
def keep_highest(current: float, new: float) -> float:
return max(current, new)
class ResearchState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
sources: Annotated[list[str], operator.add]
findings: Annotated[list[str], operator.add]
confidence: Annotated[float, keep_highest]
summary: str
current_step: str
model = init_chat_model("openai:gpt-4.1-mini")
def search_node(state: ResearchState) -> dict:
topic = state["messages"][-1].content
response = model.invoke(f"Find 2 facts about: {topic}. Separate them with '|'.")
findings = [f.strip() for f in response.content.split("|")]
return {
"sources": ["web_search", "knowledge_base"],
"findings": findings,
"confidence": 0.6,
"current_step": "analyze"
}
def analyze_node(state: ResearchState) -> dict:
findings_text = "\n".join(state["findings"])
response = model.invoke(f"Analyze this and add one concise observation:\n{findings_text}")
return {
"findings": [f"Analysis: {response.content.strip()}"],
"confidence": 0.82,
"current_step": "summarize"
}
def summarize_node(state: ResearchState) -> dict:
findings_text = "\n".join(state["findings"])
response = model.invoke(f"Summarize in one sentence:\n{findings_text}")
return {"summary": response.content.strip(), "current_step": "done"}
graph_builder = StateGraph(ResearchState)
graph_builder.add_node("search", search_node)
graph_builder.add_node("analyze", analyze_node)
graph_builder.add_node("summarize", summarize_node)
graph_builder.add_edge(START, "search")
graph_builder.add_edge("search", "analyze")
graph_builder.add_edge("analyze", "summarize")
graph_builder.add_edge("summarize", END)
graph = graph_builder.compile()
result = graph.invoke({
"messages": [HumanMessage(content="What is RAG?")],
"sources": [], "findings": [], "confidence": 0.0,
"summary": "", "current_step": "search"
})
print(f"Sources: {result['sources']}")
print(f"Findings: {len(result['findings'])} items")
print(f"Confidence (highest): {result['confidence']}")
print(f"Summary: {result['summary'][:100]}...")
# Output (example):
# Sources: ['web_search', 'knowledge_base']
# Findings: 3 items
# Confidence (highest): 0.82
# Summary: RAG combines document retrieval with text generation to...
Explanation: sources and findings accumulate from multiple nodes. confidence keeps the highest value (custom reducer). summary gets replaced with the latest value.
Exercise 6: Reducer with validation (Advanced)
Create a reducer that accumulates errors but raises an exception if it goes past 3 errors (a circuit breaker). Demonstrate it with a graph that reports errors progressively.
See solution
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
MAX_ERRORS = 3
def accumulate_with_limit(current: list[str], new: list[str]) -> list[str]:
combined = current + new
if len(combined) > MAX_ERRORS:
raise ValueError(f"Too many errors ({len(combined)}). Maximum: {MAX_ERRORS}")
return combined
class RobustState(TypedDict):
results: Annotated[list[str], operator.add]
errors: Annotated[list[str], accumulate_with_limit]
def step_with_error(state: RobustState) -> dict:
return {"errors": ["Error: API timeout"]}
def step_with_two_errors(state: RobustState) -> dict:
return {"errors": ["Error: invalid data", "Error: wrong format"]}
graph_builder = StateGraph(RobustState)
graph_builder.add_node("error1", step_with_error)
graph_builder.add_node("error2", step_with_two_errors)
graph_builder.add_node("error3", step_with_error)
graph_builder.add_edge(START, "error1")
graph_builder.add_edge("error1", "error2")
graph_builder.add_edge("error2", "error3")
graph_builder.add_edge("error3", END)
graph = graph_builder.compile()
try:
result = graph.invoke({"results": [], "errors": []})
except ValueError as e:
print(f"Graph halted: {e}")
# Output: Graph halted: Too many errors (4). Maximum: 3
Explanation: The reducer acts as a circuit breaker: it accumulates errors up to the threshold, then raises an exception and halts the graph. Useful in production to avoid degraded runs.
Summary
In this capsule you learned:
- TypedDict is the foundation of state in LangGraph — it defines which fields your graph has, with explicit types
- Without
Annotated, fields get replaced (the last node to write wins) - With
Annotated[list, operator.add], fields accumulate (each node appends items) - Custom reducers give you full control over how values get combined (deduplicate, cap, validate)
- MessagesState is the prebuilt shortcut for chat applications (messages with operator.add already configured)
- State is the single source of truth — nodes should not use global variables or side effects
- Nodes return partial updates — only the fields they want to modify
- Designing state well is designing your graph well — invest time here before writing nodes
Next capsule: Compilation and Execution — how to compile your graph, run it with invoke and stream, and visualize it with draw_mermaid_png().
Additional resources
- State Management — LangGraph Docs — Official reference on how state works in LangGraph
- Reducers — LangGraph Docs — Detailed documentation on reducers and Annotated
- MessagesState — LangGraph API — Reference for the prebuilt chat state
- TypedDict — Python Docs — Official TypedDict documentation
- Annotated — Python Docs — Official Annotated documentation
- operator — Python Docs — Reference for the operator module, including
operator.add - How to define graph state — Step-by-step tutorial for defining state
- LangGraph Quick Start — Official introductory tutorial with state examples
Module 5 — LangChain & LangGraph: From Chains to Agents