When building LLM-based workflows using LangGraph and LangChain, one of the most fundamental concepts is managing state. This “state” refers to the data passed between nodes, retained memory, routing decisions, and more.
To define and validate state structures effectively, we often rely on a few core Python typing tools and Pydantic models. In this post, we’ll break down the following key constructs that frequently appear in LangGraph workflows:
Dict
TypedDict
Annotated
Field
BaseModel
Based on research, in LangGraph, defining state using a combination of TypedDict
and Annotated
is generally considered the safest and most intuitive approach.
While using BaseModel
is not necessarily wrong, it introduces a dependency on pydantic
, which can be an additional burden.
As a result, frequent conversions using .dict()
may be required, adding extra overhead.
Ultimately, since LangGraph emphasizes lightweight internal design, TypedDict
is considered more suitable.
Dict
In the previous post, we learned about Dict, Any, Optional from the typing
module to clarify type definitions. In Python, Dict
is used only for static type checking and does not enforce type restrictions at runtime.
This is an example that shows how the static type checker mypy
responds when Python type hints (such as Dict[str, str]
) do not match the actual values.
As mentioned earlier, errors like these are not detected at runtime.
- Case 1. Type Match (Valid Behavior)
1
2
3
4
5
| from typing import Dict
state1: Dict[str, str] = {"tool_used": "search", "count": "1", "result": "OK"}
print(type(state1))
print(state1)
|
- The type hint
Dict[str, str]
specifies that all keys and values must be strings. - Since all the actual values are strings,
mypy
performs type checking without raising any errors.
1
2
3
| # Run in Jupyter Notebook
<class 'dict'>
{'tool_used': 'search', 'count': '1', 'result': 'OK'}
|
1
2
3
| # mypy zMyImsi.py
(MyDev) D:\02.MyCode\GP-MyReference\14.MyLLM_LangGraph>mypy zMyImsi.py
Success: no issues found in 1 source file
|
- Case 2. Type Mismatch (
count
is int
)
1
2
3
4
5
| from typing import Dict
state2: Dict[str, str] = {"tool_used": "search", "count": 1, "result": "OK"}
print(type(state2))
print(state2)
|
- The value
1
assigned to "count"
is an integer, which violates the Dict[str, str]
type hint. - when using
mypy
for type checking, it detects a type mismatch error. - However, since Python is a dynamically typed language, it runs without any error at runtime.
1
2
3
| # Run in Jupyter Notebook
<class 'dict'>
{'tool_used': 'search', 'count': 1, 'result': 'OK'}
|
1
2
3
4
| # mypy zMyImsi.py
(MyDev) D:\02.MyCode\GP-MyReference\14.MyLLM_LangGraph>mypy zMyImsi.py
zMyImsi.py:9: error: Dict entry 1 has incompatible type "str": "int"; expected "str": "str" [dict-item]
Found 1 error in 1 file (checked 1 source file)
|
TypedDict
Since writing this post, I’ve decided to use TypedDict
instead of dict
when working with LangGraph.
Just like with Dict
, Type errors are not caught at runtime, They can be detected by static type checkers like mypy
.
The key difference is that TypedDict
can detect missing required keys based on the defined structure.
- Case 1. Type Match (Valid Behavior)
1
2
3
4
5
6
7
8
9
10
| from typing import Dict, TypedDict
class MyState(TypedDict):
tool_used: str
count : int
result: str
state1: MyState = {"tool_used": "search", "count": 1, "result": "OK"}
print(type(state1))
print(state1)
|
MyState
is a TypedDict
with three explicitly defined fields.- Since all field types match exactly, the
mypy
static type check passes without any issues.
1
2
3
| # Run in Jupyter Notebook
<class 'dict'>
{'tool_used': 'search', 'count': 1, 'result': 'OK'}
|
1
2
3
| # mypy zMyImsi.py
(MyDev) D:\02.MyCode\GP-MyReference\14.MyLLM_LangGraph>mypy zMyImsi.py
Success: no issues found in 1 source file
|
- Case 2. Type Mismatch (
count
is str
)
1
2
3
4
5
6
7
8
9
10
| from typing import Dict, TypedDict
class MyState(TypedDict):
tool_used: str
count : int
result: str
state1: MyState = {"tool_used": "search", "count": "1", "result": "OK"}
print(type(state1))
print(state1)
|
"count"
is defined as an int
, but "1"
is a str
, resulting in a type mismatch.- when using
mypy
for type checking, it detects a type mismatch error. - However, since Python is a dynamically typed language, it runs without any error at runtime.
1
2
3
| # Run in Jupyter Notebook
<class 'dict'>
{'tool_used': 'search', 'count': '1', 'result': 'OK'}
|
1
2
3
4
| # mypy zMyImsi.py
(MyDev) D:\02.MyCode\GP-MyReference\14.MyLLM_LangGraph>mypy zMyImsi.py
zMyImsi.py:13: error: Incompatible types (expression has type "str", TypedDict item "count" has type "int") [typeddict-item]
Found 1 error in 1 file (checked 1 source file)
|
- Case 3. Missing Key (
count
is absent)
1
2
3
4
5
6
7
8
9
10
| from typing import Dict, TypedDict
class MyState(TypedDict):
tool_used: str
count : int
result: str
state3: MyState = {"tool_used": "search", "result": "OK"}
print(type(state3))
print(state3)
|
- By default,
TypedDict
considers all fields as required. - Since the
"count"
key is missing, mypy
detects it as an error. - However, since Python is a dynamically typed language, it runs without any error at runtime.
1
2
3
| # Run in Jupyter Notebook
<class 'dict'>
{'tool_used': 'search', 'result': 'OK'}
|
1
2
3
4
| # mypy zMyImsi.py
(MyDev) D:\02.MyCode\GP-MyReference\14.MyLLM_LangGraph>mypy zMyImsi.py
zMyImsi.py:10: error: Missing key "count" for TypedDict "MyState" [typeddict-item]
Found 1 error in 1 file (checked 1 source file)
|
Annotated
Annotated
is a feature provided by Python’s typing
module that allows you to attach metadata (additional information) to existing types.
- It enhances code readability and documentation quality by combining type hints with descriptive annotations.
- It is commonly used in frameworks like LangGraph when defining state, to specify field-level descriptions, default values, or fixed values as additional metadata.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| from typing import TypedDict
from typing import Annotated
from pydantic import Field
# TypedDict 사용
class MyState(BaseModel):
tool_used: Annotated[str, Field(..., min_length=3, max_length=50, description="Name of the tool being called")]
count: Annotated[int, Field(gt=1, lt=10, description="Number of times the node has executed (1–10)")]
edge: Annotated[int, Field(..., description="Number of edges connected to the node (1–10)")]
result: Annotated[str, Field(..., description="Execution result of the node")]
state1: MyState = {"tool_used": "search", "count": 1, "edge": 3, "result": "OK"}
print(type(state1))
print(state1)
|
1
2
| <class 'dict'>
{'tool_used': 'search', 'result': 'OK'}
|
BaseModel
Using pydantic.BaseModel
, you can convert dictionary-based data into a class-based model that supports runtime validation, automatic type conversion, and serialization.
However, as mentioned earlier, since LangGraph emphasizes lightweight internal design, TypedDict
is considered more suitable than BaseModel
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| from typing import Annotated
from pydantic import BaseModel, Field, ValidationError
# Define the state using BaseModel
class MyState(BaseModel):
tool_used: Annotated[str, Field(..., min_length=3, max_length=50, description="Name of the tool being called")]
count: Annotated[int, Field(gt=1, lt=10, description="Number of times the node has executed (1–10)")]
edge: Annotated[int, Field(..., description="Number of edges connected to the node (1–10)")]
result: Annotated[str, Field(..., description="Execution result of the node")]
# Attempt to create a valid instance
try:
state1 = MyState(tool_used="search", count=5, edge=3, result="OK")
print("Valid state data:", state1)
except ValidationError as e:
print("Validation error:", e)
# Attempt to create an invalid instance
try:
state2 = MyState(tool_used="s", count=111, edge=3, result="OK")
print("Valid state data:", state2)
except ValidationError as e:
print("Validation error:")
for error in e.errors():
print(f"- {error['loc'][0]}: {error['msg']}")
|
1
2
3
4
5
| # Run in Jupyter Notebook
Valid state data: tool_used='search' count=5 edge=3 result='OK'
Validation error:
- tool_used: String should have at least 3 characters
- count: Input should be less than 10
|