| name | effect-graph |
| description | Build and analyze immutable graphs with Effect's Graph module — directed/undirected construction, scoped mutation (mutate/addNode/addEdge), node/edge queries, lazy DFS/BFS/topo walkers, algorithms (isAcyclic, stronglyConnectedComponents, dijkstra/astar/bellmanFord/floydWarshall), and GraphViz/Mermaid export. Use when modeling dependency graphs, ordering tasks topologically, detecting cycles, computing reachability or shortest paths, or rendering relationship diagrams. |
You are an Effect TypeScript expert specializing in the Graph module — building, querying, traversing, and running algorithms over immutable graphs.
Effect Source Reference
The Effect v4 source is at ~/.cache/effect-v4/. Read it directly when in doubt — the module is self-contained in a single file.
Key files:
packages/effect/src/Graph.ts — the entire module: types, constructors, mutation scope, queries, walkers, traversals, algorithms, GraphViz/Mermaid export
packages/effect/test/Graph.test.ts — edge-case semantics: multi-edges, self-loops, undefined node/edge data, negative-weight behavior, walker re-iteration, topo initials
Core Model
A Graph<N, E, T extends Kind = 'directed'> is an immutable graph storing user data N on nodes and E on edges, where T is 'directed' | 'undirected'. Writes only happen on a MutableGraph<N, E, T> inside an explicit mutation scope; everything else (queries, traversals, algorithms) accepts either form.
import { Graph, Option } from 'effect';
import * as Graph from 'effect/Graph';
The pieces:
Graph.NodeIndex / Graph.EdgeIndex — plain number identifiers. They are allocated sequentially from 0 and never reused after removal; they are stable IDs, not array offsets.
Graph.Edge<E> — a Data.Class with { source: NodeIndex; target: NodeIndex; data: E }.
Graph.DirectedGraph<N, E> / Graph.UndirectedGraph<N, E> — aliases for Graph<N, E, 'directed' | 'undirected'>; MutableDirectedGraph / MutableUndirectedGraph are the mutable counterparts.
Graph.GraphError — a Data.TaggedError('GraphError')<{ message: string }>. The Graph API is fully synchronous: nothing returns Effect. Invalid operations throw GraphError; lookups return Option.
Graph.Walker<T, N> — the lazy iterator wrapper returned by all traversal and listing APIs.
Call conventions:
- Read APIs are dual (data-first or pipeable data-last):
Graph.neighbors(graph, 0) or graph.pipe(Graph.neighbors(0)).
- Write APIs are data-first only and take the
MutableGraph as the first argument: Graph.addNode(mutable, data).
- Graphs implement
Equal, Hash, Pipeable, Inspectable, and are iterable over [NodeIndex, N] node entries.
1. Creating Graphs
Graph.directed and Graph.undirected create empty graphs, optionally running an initial mutation callback:
import { Graph } from 'effect';
const dag = Graph.directed<string, number>((mutable) => {
const a = Graph.addNode(mutable, 'A');
const b = Graph.addNode(mutable, 'B');
const c = Graph.addNode(mutable, 'C');
Graph.addEdge(mutable, a, b, 1);
Graph.addEdge(mutable, b, c, 2);
});
const social = Graph.undirected<{ name: string }, string>((mutable) => {
const alice = Graph.addNode(mutable, { name: 'Alice' });
const bob = Graph.addNode(mutable, { name: 'Bob' });
Graph.addEdge(mutable, alice, bob, 'friends');
});
Graph.isGraph(dag);
Notes:
- The type parameters are
<NodeData, EdgeData>; the kind is fixed by the constructor.
- Edge data can be
void/undefined — pass undefined explicitly: Graph.addEdge(m, a, b, undefined).
- Self-loops (
addEdge(m, a, a, data)) and parallel edges between the same pair are allowed.
- For directed graphs,
source -> target direction matters everywhere (traversal, topo, neighbors). For undirected graphs, the stored source/target are arbitrary endpoints; all queries and algorithms treat the edge symmetrically.
2. Mutation: Scoped Writes
All writes go through a mutation scope. Prefer Graph.mutate (dual), which copies the graph, applies your function, and returns a new immutable graph:
const bigger = Graph.mutate(dag, (mutable) => {
const d = Graph.addNode(mutable, 'D');
Graph.addEdge(mutable, 2, d, 3);
});
beginMutation / endMutation exist for manual control, but discard the MutableGraph after endMutation — the returned immutable graph shares adjacency state with it, so further writes to the old mutable value would corrupt the snapshot. mutate avoids this footgun entirely. Each scope costs an O(V+E) copy, so batch all changes into one mutate call instead of chaining many.
Write operations (all take the MutableGraph first; all return void except the two add*):
Graph.mutate(dag, (m) => {
const idx = Graph.addNode(m, 'X');
const e = Graph.addEdge(m, 0, idx, 9);
Graph.updateNode(m, idx, (data) => data.toLowerCase());
Graph.updateEdge(m, e, (w) => w * 2);
Graph.removeEdge(m, e);
Graph.removeNode(m, idx);
});
3. Node & Edge Queries
Read APIs work on both Graph and MutableGraph, and are dual:
Graph.nodeCount(dag);
Graph.edgeCount(dag);
Graph.hasNode(dag, 0);
Graph.getNode(dag, 0);
Graph.getEdge(dag, 0);
Graph.hasEdge(dag, 0, 1);
Graph.findNode(dag, (data) => data === 'B');
Graph.findNodes(dag, (data) => data !== 'B');
Graph.findEdge(dag, (data, source, target) => data > 1);
Graph.findEdges(dag, (data) => data >= 1);
Neighbors:
Graph.neighbors(dag, 0);
Graph.successors(dag, 0);
Graph.predecessors(dag, 1);
Graph.neighborsDirected(graph, node, direction) still exists but is deprecated as of 4.0 — use successors / predecessors.
- Directed neighbor lists have one entry per edge, so parallel edges yield duplicates; undirected
neighbors deduplicates (a node with two parallel edges to the same peer reports it once; a self-loop reports the node itself once).
- There is no dedicated degree function — use
Graph.successors(g, n).length (out-degree) and Graph.predecessors(g, n).length (in-degree), or Graph.neighbors(g, n).length for undirected.
4. Bulk Transformations
These run inside a mutation scope and modify the MutableGraph in place. Indices are preserved by the map* variants; the filter* variants remove (node removal also drops incident edges):
const transformed = Graph.mutate(dag, (m) => {
Graph.mapNodes(m, (data) => data.toUpperCase());
Graph.mapEdges(m, (w) => w * 10);
Graph.filterNodes(m, (data) => data !== 'C');
Graph.filterEdges(m, (w) => w >= 10);
});
import { Option } from 'effect';
const pruned = Graph.mutate(dag, (m) => {
Graph.filterMapNodes(m, (data) =>
data.startsWith('A') ? Option.some(data.toLowerCase()) : Option.none()
);
Graph.filterMapEdges(m, (w) => (w > 1 ? Option.some(w * 2) : Option.none()));
});
const reversed = Graph.mutate(dag, (m) => {
Graph.reverse(m);
});
5. Walkers: Lazy Iterators
Every traversal and listing API returns a Graph.Walker<Index, Data> — a lazy iterable of [index, data] pairs. Aliases: Graph.NodeWalker<N> = Walker<NodeIndex, N> and Graph.EdgeWalker<E> = Walker<EdgeIndex, Edge<E>>.
const walker = Graph.dfs(dag, { start: [0] });
Array.from(Graph.indices(walker));
Array.from(Graph.values(walker));
Array.from(Graph.entries(walker));
Array.from(walker.visit((index, data) => ({ id: index, name: data })));
for (const [index, data] of walker) {
console.log(index, data);
}
Walker semantics:
- Re-iterable with fresh state — each
for...of / Array.from restarts the traversal from scratch.
- Lazy — the graph is read during iteration; elements removed since walker creation are skipped.
Listing walkers:
Graph.nodes(dag);
Graph.edges(dag);
Graph.externals(dag, { direction: 'outgoing' });
Graph.externals(dag, { direction: 'incoming' });
- On undirected graphs every incident edge appears in both adjacency directions, so
externals yields only isolated nodes regardless of direction — find leaves with Graph.neighbors(g, n).length === 1 instead.
6. Traversals: DFS, BFS, Postorder, Topological
dfs, bfs, and dfsPostOrder take a SearchConfig: { start?: Array<NodeIndex>; direction?: 'outgoing' | 'incoming' }. All are dual and return a NodeWalker<N>:
const down = Graph.dfs(dag, { start: [0] });
const up = Graph.dfs(dag, { start: [2], direction: 'incoming' });
const levels = Graph.bfs(dag, { start: [0] });
const bottomUp = Graph.dfsPostOrder(dag, { start: [0] });
- Omitting
start (or passing []) yields an empty iterator — traversals do not default to all nodes; seed them explicitly (multiple start nodes cover disconnected components).
- A missing start node throws
GraphError at walker-creation time.
direction is ignored for undirected graphs — both endpoints are always followed.
- Each node is visited at most once; cycles are safe.
Topological sort (topo) uses Kahn's algorithm and takes TopoConfig: { initials?: Array<NodeIndex> }:
const order = Array.from(Graph.indices(Graph.topo(dag)));
const prioritized = Graph.topo(dag, { initials: [0] });
topo throws GraphError when called on an undirected graph or a cyclic graph ('Cannot perform topological sort on cyclic graph') — guard with Graph.isAcyclic first. An initials entry that has incoming edges throws 'Initial node N has incoming edges' when iteration begins (not at creation).
7. Structure Analysis: Cycles, Components, Bipartite
Graph.isAcyclic(dag);
Graph.isBipartite(social);
Graph.connectedComponents(social);
Graph.stronglyConnectedComponents(dag);
isAcyclic is cached: fresh graphs are known-acyclic, the flag is invalidated when a mutation may change the answer (addEdge on a known-acyclic graph, removals on a known-cyclic graph) and unconditionally by reverse, and a computed result is memoized on the graph value. Repeated calls are cheap.
8. Shortest Paths
Point-to-point algorithms return Option.Option<Graph.PathResult<E>> where PathResult is:
interface PathResult<E> {
readonly path: Array<NodeIndex>;
readonly distance: number;
readonly costs: Array<E>;
}
All of them throw GraphError if source or target does not exist, and return Option.none() when the target is unreachable. source === target succeeds immediately with { path: [source], distance: 0, costs: [] }. Undirected graphs are traversed symmetrically regardless of stored edge orientation.
const weighted = Graph.directed<string, number>((m) => {
const a = Graph.addNode(m, 'A');
const b = Graph.addNode(m, 'B');
const c = Graph.addNode(m, 'C');
Graph.addEdge(m, a, b, 5);
Graph.addEdge(m, a, c, 10);
Graph.addEdge(m, b, c, 2);
});
const shortest = Graph.dijkstra(weighted, {
source: 0,
target: 2,
cost: (edgeData) => edgeData
});
const grid = Graph.directed<{ x: number; y: number }, number>((m) => {
const a = Graph.addNode(m, { x: 0, y: 0 });
const b = Graph.addNode(m, { x: 1, y: 0 });
const c = Graph.addNode(m, { x: 2, y: 0 });
Graph.addEdge(m, a, b, 1);
Graph.addEdge(m, b, c, 1);
});
const route = Graph.astar(grid, {
source: 0,
target: 2,
cost: (edgeData) => edgeData,
heuristic: (nodeData, targetData) =>
Math.abs(nodeData.x - targetData.x) + Math.abs(nodeData.y - targetData.y)
});
const withNegatives = Graph.bellmanFord(weighted, {
source: 0,
target: 2,
cost: (edgeData) => edgeData
});
const all = Graph.floydWarshall(weighted, (edgeData) => edgeData);
all.distances.get(0)?.get(2);
all.paths.get(0)?.get(2);
all.costs.get(0)?.get(2);
Sharp edges (all verified in tests):
dijkstra and astar validate every edge weight in the graph eagerly — any negative or NaN cost throws GraphError immediately, even when the offending edge is not on the path and even when source === target.
- In undirected graphs every edge is traversable in both directions, so any reachable negative edge is a negative cycle:
bellmanFord returns Option.none(), floydWarshall throws 'Negative cycle detected...'.
floydWarshall throws on any negative cycle in directed graphs too; it runs in O(V^3) — fine for hundreds of nodes, not tens of thousands.
- With parallel edges,
floydWarshall uses the minimum weight between a pair.
9. Equality, Hashing & Inspection
Graphs implement Equal and Hash. Equality compares kind, then node data and edge data by index using Equal.equals (works with Data/Schema classes and plain primitives, including undefined data):
import { Equal } from 'effect';
Equal.equals(graph1, graph2);
Because comparison is index-keyed, two structurally identical graphs built in a different insertion order (or after different removal histories) are not equal. Treat Equal as "same construction", not graph isomorphism.
Inspection:
String(dag);
dag.toJSON();
for (const [index, data] of dag) {
console.log(index, data);
}
10. Visualization: GraphViz & Mermaid
Both exporters are dual, work on directed and undirected graphs, and return a string. All options are optional — Graph.toMermaid(dag) works as-is; labels default to String(data), the GraphViz name to 'G', the Mermaid direction to 'TD', and shapes to rectangle:
const dot = Graph.toGraphViz(dag, {
nodeLabel: (data) => `Task: ${data}`,
edgeLabel: (w) => `w=${w}`,
graphName: 'Pipeline'
});
const mermaid = Graph.toMermaid(dag, {
nodeLabel: (data) => data,
edgeLabel: (w) => String(w),
direction: 'LR',
nodeShape: (data) => (data === 'A' ? 'stadium' : 'rectangle')
});
MermaidNodeShape values: 'rectangle' | 'rounded' | 'circle' | 'diamond' | 'hexagon' | 'stadium' | 'subroutine' | 'cylindrical'. Mermaid labels are escaped for special characters automatically; empty edge labels render plain arrows.
Key Patterns
Dependency graph with task ordering
findNode is O(n), so keep your own key-to-index map while building:
import { Graph } from 'effect';
interface Task {
readonly name: string;
}
const byName = new Map<string, Graph.NodeIndex>();
const tasks = Graph.directed<Task, void>((m) => {
const add = (name: string) => {
const index = Graph.addNode(m, { name });
byName.set(name, index);
return index;
};
const compile = add('compile');
const test = add('test');
const lint = add('lint');
const release = add('release');
Graph.addEdge(m, compile, test, undefined);
Graph.addEdge(m, compile, lint, undefined);
Graph.addEdge(m, test, release, undefined);
Graph.addEdge(m, lint, release, undefined);
});
if (!Graph.isAcyclic(tasks)) {
const cycles = Graph.stronglyConnectedComponents(tasks).filter(
(scc) => scc.length > 1
);
throw new Error(`Dependency cycles: ${JSON.stringify(cycles)}`);
}
const executionOrder = Array.from(
Graph.topo(tasks).visit((_, task) => task.name)
);
Reachability and impact analysis
const impacted = new Set(
Graph.indices(Graph.dfs(tasks, { start: [byName.get('compile')!] }))
);
const prerequisites = new Set(
Graph.indices(
Graph.dfs(tasks, { start: [byName.get('release')!], direction: 'incoming' })
)
);
const roots = Array.from(Graph.indices(Graph.externals(tasks, { direction: 'incoming' })));
const leaves = Array.from(Graph.indices(Graph.externals(tasks, { direction: 'outgoing' })));
Weighted routing with fallback
import { Graph, Option } from 'effect';
interface Link {
readonly latencyMs: number;
}
const route = (
network: Graph.UndirectedGraph<string, Link>,
from: Graph.NodeIndex,
to: Graph.NodeIndex
): Array<Graph.NodeIndex> =>
Graph.dijkstra(network, {
source: from,
target: to,
cost: (link) => link.latencyMs
}).pipe(
Option.map((result) => result.path),
Option.getOrElse(() => [])
);
Wrapping throwing operations in Effect
Graph operations throw GraphError synchronously. Inside effectful code, capture them with Effect.try (the thrown value already is a tagged error; see the effect-error-handling skill for the broader strategy):
import { Effect, Graph } from 'effect';
const topoOrder = (g: Graph.DirectedGraph<string, number>) =>
Effect.try({
try: () => Array.from(Graph.indices(Graph.topo(g))),
catch: (error) => error as Graph.GraphError
});
Derive a filtered subgraph view
const activeOnly = Graph.mutate(deployments, (m) => {
Graph.filterNodes(m, (service) => service.status === 'active');
});
const clusters = Graph.connectedComponents(activeOnly);
Common Mistakes
- Calling
addNode/addEdge on an immutable Graph — write APIs require a MutableGraph, obtained only via the constructor callback, Graph.mutate, or beginMutation. The types reject it; restructure into a mutate scope.
- Expecting Effect-returning APIs — the whole module is synchronous. Failures throw
GraphError (addEdge with a missing endpoint, topo on cyclic/undirected graphs, dijkstra/astar on negative weights, missing traversal start nodes); lookups return Option. Wrap with Effect.try when inside effectful code.
- Passing
{ cost } to floydWarshall — it takes a bare cost function: Graph.floydWarshall(graph, (edgeData) => edgeData). Only dijkstra/astar/bellmanFord take a { source, target, cost } config object.
- Consuming a Walker as if it yielded indices —
dfs/bfs/topo/nodes walkers yield [index, data] tuples. Use Graph.indices(w), Graph.values(w), Graph.entries(w), or w.visit((i, d) => ...) to project.
- Expecting traversals to cover the whole graph by default — omitting
start yields an empty iterator. Seed every component explicitly (start: [a, b]), or use Graph.nodes for plain enumeration.
- Using
neighborsDirected — deprecated in 4.0. Use Graph.successors (outgoing) / Graph.predecessors (incoming); all three throw GraphError on undirected graphs — use Graph.neighbors there.
- Reusing a
MutableGraph after endMutation — the returned immutable graph shares adjacency state with the mutable one; further writes corrupt the snapshot. Use Graph.mutate, which scopes the lifetime for you.
- Assuming a negative weight is fine if it is off the path —
dijkstra and astar validate every edge weight in the graph up front and throw GraphError for any negative/NaN cost, even when source === target. Use bellmanFord for negative weights.
- Negative edges in undirected graphs — each undirected edge is traversable both ways, so any reachable negative edge forms a negative cycle:
bellmanFord returns Option.none(), floydWarshall throws.
- Treating
Equal.equals as graph isomorphism — equality is index-keyed. Same structure built in a different order (different indices) compares unequal.
- Reading
PathResult.costs as numbers — costs holds the original edge data E along the path, not the output of your cost function. distance is the numeric total.
- Treating
NodeIndex as an array offset — indices are stable identifiers; after removals they are sparse and never reused, so nodeCount is not max index + 1. Iterate via Graph.nodes/Graph.indices instead of counting up.
- Relying on
updateNode/removeNode to signal missing indices — updateNode, updateEdge, removeNode, and removeEdge are silent no-ops for nonexistent indices; check hasNode/hasEdge first if absence is a bug.
- Passing
initials with incoming edges to topo — initials must be zero in-degree nodes; others throw GraphError when iteration starts. initials only prioritizes queue order — every node is still emitted exactly once.