Skip to content

Railway Diagrams

The railway diagram is the signature visualization of effect-analyzer. Inspired by the railway-oriented programming pattern, it renders your Effect program as a straight-line happy path with error branches forking off at failure points.

flowchart LR
A["AccountService"] -->|ok| B["AuditLog"]
B -->|ok| C["accounts.getBalance"]
C -->|ok| D["decision"]
D -->|ok| E["accounts.debit"]
E -->|ok| F["accounts.credit"]
F -->|ok| G["audit.record"]
G -->|ok| Done((Success))
D -->|err| DE["InsufficientFundsError"]

In a railway diagram, the main track represents the success path - the sequence of operations that execute when everything goes right. At each point where a failure can occur, an error branch splits off to show what happens on failure.

This makes it immediately clear:

  • What the program does when it succeeds
  • Where failures can happen
  • What error types are produced at each point
Terminal window
npx effect-analyze ./src/transfer.ts

Linear programs pick railway in auto mode. --format mermaid-railway forces it. The default also writes transfer.effect-analysis.md with the same diagram.

Steps are labelled with the callee (accounts.getBalance), not a yield binding. Error nodes use the TaggedError _tag when the class has one (Data.TaggedError("NOT_FOUND") renders as NOT_FOUND).

tapError, tapErrorTag, tapErrorCause, tapDefect and onError run a callback on the error rail and let the failure continue. The diagram draws each one as a dotted branch off the step it guards, labelled with what the callback calls:

yield* bank.charge.pipe(Effect.tapError(() => store.release(id)))
flowchart LR
  A["Store"] -->|ok| B["Bank"]
  B -->|ok| C["bank.charge"]
  C -->|ok| Done((Success))
  C -->|err| CE["Declined"]
  CE -.->|tapError| CT0["store.release"]

When the step has no typed error to draw, the branch starts from the step itself.

You get the same branch from the data-first form (Effect.tapError(bank.charge, f)) and from a callback passed by reference (Effect.tapError(store.release)).

Auto mode picks the railway diagram as the baseline when your program has:

  • Low cyclomatic complexity - few branching points
  • Linear structure - mostly sequential yield* steps in a generator
  • No parallel or race patterns - those push auto mode toward the concurrency view

Programs with Effect.gen and a series of yields are the ideal fit for railway diagrams.

Railway diagrams default to left-to-right (LR) flow, which reads naturally as a timeline. Override this with the --direction flag:

Terminal window
npx effect-analyze ./src/transfer.ts --format mermaid-railway --direction TB
Direction Best For
LR Default - reads like a timeline
TB Tall, narrow programs
RL Right-to-left reading order
BT Bottom-up flow

Generate railway diagrams through the library API:

import { analyze } from "effect-analyzer/analysis"
import { renderRailwayMermaid } from "effect-analyzer/diagram"
import { Effect } from "effect"
const ir = await Effect.runPromise(analyze("./src/transfer.ts").single)
const diagram = renderRailwayMermaid(ir, { direction: "LR" })
console.log(diagram)

Enable --style-guide for cleaner diagrams that apply readability heuristics - collapsing trivial nodes, shortening labels, and reducing visual noise:

Terminal window
npx effect-analyze ./src/transfer.ts --format mermaid-railway --style-guide

Railway diagrams work best for linear, sequential programs. If your program has:

  • Heavy branching - use mermaid-decisions or the standard mermaid flowchart
  • Parallel operations - use mermaid-concurrency
  • Complex error handling - use Error Flows
  • Many services - use Service Maps