- Book: AI That Plans
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Most LangGraph material is written in Python and translated in your
head. The concepts carry over; the TypeScript does not, and the place
people lose an afternoon is the state definition — because that is
where LangGraph.js does something unusual to get you generic types.
Nodes and edges are the easy part. State is the API.
State is a schema, not an interface
You do not declare a type and hand it over. You build an annotation
object, and the graph derives its types from that.
import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
import type { BaseMessage } from "@langchain/core/messages";
import { messagesStateReducer } from "@langchain/langgraph";
const State = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: messagesStateReducer,
default: () => [],
}),
question: Annotation<string>(),
toolCalls: Annotation<number>({
reducer: (current, update) => current + update,
default: () => 0,
}),
});
type StateT = typeof State.State;
typeof State.State is the line to remember. That is your state type,
and it is what every node signature refers to. Writing a separate
interface AgentState next to this is the most common mistake — the
two drift, and the compiler only checks one of them.
Reducers decide what a return value means
This is the concept that has no equivalent in a plain agent loop.
A node returns a partial state. The reducer for each channel decides
how that partial merges with what is already there.
No reducer means replace:
question: Annotation<string>(),
// node returns { question: "x" } → state.question is now "x"
A reducer means combine:
toolCalls: Annotation<number>({
reducer: (current, update) => current + update,
default: () => 0,
}),
// node returns { toolCalls: 1 } → state.toolCalls increments
messagesStateReducer is the built-in for conversations: it appends,
and it handles message ids so an update to an existing message
replaces rather than duplicates it.
Getting this wrong produces a specific and confusing bug. Declare
messages without a reducer, and every node that returns messages
replaces the history instead of appending. The graph runs, no error
appears, and the model behaves as if it has amnesia between nodes.
If you find yourself writing return { messages: [...state.messages, inside a node, the reducer is missing. That spread is the
newMsg] }
reducer's job.
Nodes are functions of state
async function retrieve(state: StateT) {
const docs = await search(state.question);
return { context: docs };
}
async function answer(state: StateT) {
const res = await model.invoke([
new SystemMessage(render(state.context)),
...state.messages,
]);
return { messages: [res], toolCalls: res.tool_calls?.length ?? 0 };
}
Take state, return a partial. The partial is typed — return a key
that is not in the annotation and it does not compile, which is most
of the value of using TypeScript here at all.
Wiring, and the type that surprises people
const workflow = new StateGraph(State)
.addNode("retrieve", retrieve)
.addNode("answer", answer)
.addEdge(START, "retrieve")
.addEdge("retrieve", "answer")
.addEdge("answer", END);
Chain the calls. Do not split them:
// loses type information about registered node names
const g = new StateGraph(State);
g.addNode("retrieve", retrieve);
g.addEdge(START, "retrieve");
addNode returns a graph type parameterised by the node names added
so far. Chaining accumulates that; assigning to a const and calling
methods separately throws it away, and you lose compile-time checking
that an edge points at a node that exists.
That is worth internalising, because a typo in an edge target is
otherwise a runtime error deep in a graph run.
Conditional edges
Branching is a function from state to the next node name.
function route(state: StateT): "tools" | "answer" | typeof END {
const last = state.messages.at(-1);
if (last?.tool_calls?.length) return "tools";
if (state.toolCalls > 10) return END;
return "answer";
}
const workflow = new StateGraph(State)
.addNode("agent", agent)
.addNode("tools", tools)
.addNode("answer", answer)
.addEdge(START, "agent")
.addConditionalEdges("agent", route)
.addEdge("tools", "agent")
.addEdge("answer", END);
Annotate the router's return type explicitly. Without the annotation
TypeScript widens it to string, and you lose the check that every
branch names a real node.
The router is a pure function of state, which makes it the most
testable part of the whole graph:
it("routes to tools when a tool call is pending", () => {
expect(route({ ...base, messages: [withToolCall] })).toBe("tools");
});
No model, no network. Routing logic is where agent bugs live, and
this is the one part you can unit test properly.
The checkpointer is the reason to be here
Everything above is expressible as a while loop. This is not.
import { MemorySaver } from "@langchain/langgraph";
const checkpointer = new MemorySaver();
const graph = workflow.compile({ checkpointer });
const config = { configurable: { thread_id: "conv-42" } };
await graph.invoke({ question: "How do I rotate a key?" }, config);
The graph writes a checkpoint after every node. thread_id is the
key. Invoke again with the same thread_id and it resumes from the
last checkpoint with full state.
MemorySaver is in-process and dies with the process — fine for
tests, useless for the thing you actually wanted. For real
persistence use a durable saver backed by Postgres or SQLite from
@langchain/langgraph-checkpoint-*, and treat the checkpoint table as
production data: it is your agent's memory, and it grows.
Two operational facts that follow. Checkpoints accumulate per thread,
so you need a retention policy. And anything in state is serialised —
put a class instance with methods in there and it comes back as a
plain object.
// survives a checkpoint round trip
type Doc = { id: string; text: string; score: number };
// does not — methods are gone after deserialisation
class Doc { constructor(public id: string) {} render() { /* ... */ } }
Keep state as plain data.
The newer Zod-based schema
Current docs also show a StateSchema API using Zod:
import { StateGraph, StateSchema, ReducedValue } from "@langchain/langgraph";
import { z } from "zod/v4";
const State = new StateSchema({
foo: z.string(),
bar: new ReducedValue(
z.array(z.string()).default(() => []),
{ reducer: (x, y) => x.concat(y) },
),
});
Same model — channels, reducers, defaults — expressed through Zod,
which is appealing if your codebase already validates with Zod.
Annotation.Root remains what most existing code and examples use.
Pick one per project rather than mixing, and check which your
installed version supports before committing to it.
What actually goes wrong
Three failures account for most of the lost time.
Missing reducer on messages. History resets between nodes. No
error, model appears to forget.
Unchained builder calls. Edge typos become runtime errors instead
of compile errors.
MemorySaver in production. Everything works until a deploy, and
every in-flight conversation is gone.
None of them throw at the point of the mistake, which is why they
cost an afternoon rather than a minute.
If this was useful
AI That Plans covers
LangGraph.js properly — state design, reducers, routing, durable
checkpointers, and the multi-agent patterns built on top of them.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)