Composable, inspectable workflows with immutable state and recoverable failures.
Berylx is a TypeScript port of the Ruby berylx gem. It gives multistep workflows a small set of building blocks: named tasks, sequences, branches, parallel composition, and recovery handlers.
Documentation, including a detailed introduction and runnable examples, lives at https://lib.minamorl.com/berylx/.
Each task receives a Focus (also called Lay) and produces a result:
Task: Focus<State> -> Ok<State> | Err<State>
Successful results carry the updated state. Failed results carry partial state
and a structured error, so recovery code can work with the context of the failure.
A Root owns committed state and commits a workflow's result only when it succeeds.
Use Berylx to compose workflows within a process. Persistence, job scheduling, and distributed coordination belong to the application running the workflow.
Install the published package from npm:
pnpm add @minamorl/berylxThe examples in this repository track the current source tree and may describe APIs that are not yet available in the published package.
The package uses ES modules, includes TypeScript declarations, and requires Node.js 18 or later.
Declare the state type once with berylx<S>(), then build tasks using that type:
import { berylx } from "@minamorl/berylx";
interface GreetingState {
name: string;
greeting: string;
}
const b = berylx<GreetingState>();
const stripName = b.task("strip_name", (focus) =>
focus.at("name").update((name) => name.trim()),
);
const greet = b.task("greet", (focus) =>
focus.at("greeting").set(`Hello, ${focus.at("name").get()}!`),
);
const workflow = stripName.then(greet);
const root = b.root({ name: " Mina ", greeting: "" });
const result = root.pipe(workflow);
result.focus.toObject();
// { name: 'Mina', greeting: 'Hello, Mina!' }
root.state();
// { name: 'Mina', greeting: 'Hello, Mina!' }then passes each successful result to the next task. Running the entire sequence
through root.pipe(workflow) commits once, after the sequence succeeds. If it
returns Err, the root keeps its previously committed state and the result retains
the partial state.
Focus<S, P> tracks both the root state type S and the current path P.
at(key) moves to a child, get() reads the focused value, and set() or
update() returns a new focus at the root. The original focus remains unchanged.
import { Focus } from "@minamorl/berylx";
const original = Focus.of({ user: { name: " Mina ", age: 21 } });
const updated = original
.at("user")
.at("name")
.update((name) => name.trim());
original.at("user").at("name").get(); // ' Mina '
updated.at("user").at("name").get(); // 'Mina'
updated.toObject(); // { user: { name: 'Mina', age: 21 } }
// @ts-expect-error Only 'name' and 'age' are valid keys here.
original.at("user").at("nmae");Plain objects and arrays are defensively copied and deeply frozen. Values such as
Map, Set, Date, typed arrays, and class instances are retained as supplied;
their internals are not frozen by Focus.
A workflow keeps one state type throughout composition. set() must accept the
type at its path; it does not widen the state type. Include fields that later
steps populate in your state interface. This keeps composition and error messages
manageable without modeling every step as a separate Task<Input, Output> type.
berylx<S>() is a convenience wrapper around constructors such as Task.of<S>(),
Root.of(), and Flow.of(). It returns the same classes and adds no separate
execution mechanism. For dynamic state, unparameterized Task.of() and
Focus<any> remain available. Lay is an alias for Focus.
| Operation | API | Behavior |
|---|---|---|
| Sequence | a.then(b) |
Run b with the successful result of a. |
| Parallel composition | a.par(b) |
Run both branches from the same snapshot and merge their results. |
| Conditional branch | When.of(name, predicate).then(task) |
Run an arm when its predicate matches. |
| Additional arm | arm.or(otherArm) |
Try arms in order; the first match wins. |
| Fallback arm | arm.or(Else.then(task)) |
Supply a final unconditional arm. |
| Recovery boundary | workflow.then(Catch.of(...)) |
Recover an earlier failure and allow the sequence to continue. |
| Recovery wrapper | workflow.rescueWith(handler) |
Run a handler if the wrapped workflow fails. |
| Execute and commit | root.pipe(workflow) |
Commit only a successful result. |
| Execute without a root | Flow.of(state).call(workflow) |
Return a result without committing to a root. |
Ordinary Task execution is synchronous, including the branches of par.
For concurrent asynchronous branches, use AsyncTask and
EffectTree.runAsync, as described below.
Every parallel branch starts from the same base snapshot. The default reducer,
Merge.strict(), compares each result with that base before combining changes:
- A branch that leaves the state unchanged does not erase another branch's work.
- Changes to different paths are preserved.
- Matching updates to the same path are accepted.
- Incompatible updates to the same path return an
Errwith codemerge_conflict.
import { Flow, Task } from "@minamorl/berylx";
const setA = Task.of("set_a", (focus) => focus.at("a").set(1));
const setB = Task.of("set_b", (focus) => focus.at("b").set(1));
const merged = Flow.of({ a: 0, b: 0 }).call(setA.par(setB));
merged.focus.toObject(); // { a: 1, b: 1 }
const paid = Task.of("paid", (focus) => focus.at("status").set("paid"));
const trial = Task.of("trial", (focus) => focus.at("status").set("trial"));
const conflict = Flow.of({ status: null }).call(paid.par(trial));
if (conflict.isErr()) {
conflict.code; // 'merge_conflict'
conflict.focus.toObject(); // { status: null }
}This is a three-way join μ_b(left, right) over base b, with both identity laws
μ_b(b, x) = x and μ_b(x, b) = x.
Merge.deep() is a two-way merge that favors the right-hand value and ignores the
base. It can overwrite independent updates to existing keys. Choose it explicitly
when that behavior is intended:
import { Merge, Parallel } from "@minamorl/berylx";
const rightBiased = new Parallel([setA, setB]).reduce(Merge.deep());Parallel defaults to short_circuit error handling. Use .accumulate() on a
Parallel instance to collect branch failures in parallelErrors.
Return focus.reject(code, message) to fail with partial state. Task exceptions
are also converted to Err results. A BerylxError records information such as
the error code, failed task, trace, cause, and parallel failures.
import { Catch, Root, Task } from "@minamorl/berylx";
const charge = Task.of("charge", (focus) =>
focus
.at("chargeAttempted")
.set(true)
.reject("payment_failed", "Card declined"),
);
const recordFailure = Catch.of("record_failure", null, {}, (error, focus) =>
focus
.at("failure")
.set(error instanceof Error ? error.message : String(error)),
);
const notify = Task.of("notify", (focus) => focus.at("notified").set(true));
const workflow = charge.then(recordFailure).then(notify);
const root = Root.of({ chargeAttempted: false });
const result = root.pipe(workflow);
result.focus.toObject();
// { chargeAttempted: true, failure: 'Card declined', notified: true }Without recordFailure, the sequence returns an Err containing
{ chargeAttempted: true }, skips notify, and leaves root.state() at
{ chargeAttempted: false }.
Catch passes successful results through and runs its handler only for a matching
failure. Fatal errors are excluded by default; { fatal: true } opts into
recovering them. Committing or rejecting state does not undo external side effects;
any compensation belongs in your recovery logic.
Use AsyncTask.of() for callbacks that return promises, and run workflows that
contain them with EffectTree.runAsync():
import { AsyncTask, EffectTree } from "@minamorl/berylx";
const loadName = AsyncTask.of("load_name", async (focus) => {
const name = await Promise.resolve("Mina");
return focus.at("name").set(name);
});
const loadTotal = AsyncTask.of("load_total", async (focus) => {
const total = await Promise.resolve(42);
return focus.at("total").set(total);
});
const result = await EffectTree.runAsync(loadName.par(loadTotal), {
name: "",
total: 0,
});
result.focus.toObject(); // { name: 'Mina', total: 42 }Synchronous and asynchronous tasks can share a workflow. The asynchronous
interpreter starts parallel branches together and waits for them all to settle
before combining results. EffectTree.runAsync() returns a result; it does not
commit to a Root automatically.
The optional Timeline API coordinates time-based events, human work, and AI or
Berylx tasks in one in-memory execution plan. A Clock only supplies the current
time; a Timeline owns the plan, observations, task states, and scheduling. Call
issue() to snapshot the plan, then start() to begin it. Applications that
advance an external clock must call advance() explicitly.
import { Timeline, predicates, type Clock } from "@minamorl/berylx/timeline";
class ManualClock implements Clock {
constructor(public current: Date) {}
now(): Date {
return this.current;
}
}
const clock = new ManualClock(new Date("2026-09-26T00:00:00Z"));
const timeline = Timeline.forExecution({}, clock);
const approval = timeline.user("approve");
timeline.ai(
"publish",
() => console.log("published"),
predicates.completed(approval),
);
const plan = timeline.issue();
await plan.start();
await plan.emit({ type: "human-completed", task: approval });Use one Timeline Root for each execution token. Predicates can inspect event and
task state, while beat positions, UTC calendar/cron selectors, and numeric
keyframes cover time-driven work. The Berylx adapter lets an AsyncTask wait for
a human login without blocking unrelated ready work. Events must never contain
credentials. See Timeline for the execution model, examples,
errors, concurrency behavior, and in-memory lifetime.
Using the workflow from the quick start, list task names without running task
callbacks:
import { EffectTree } from "@minamorl/berylx";
const dry = EffectTree.dryRun(workflow, { name: " Mina ", greeting: "" });
dry.steps; // ['strip_name', 'greet']Dry runs evaluate branch predicates against the supplied state, so predicates should be pure. Task updates are not simulated, and recovery handlers are not invoked by a simulated failure.
Compile a workflow into an inspectable graph or export it as DOT or Mermaid:
import { Graph } from "@minamorl/berylx";
const graph = Graph.from(workflow);
graph.nodes(); // ['strip_name', 'greet']
graph.toDot(); // A Graphviz digraph.
graph.toMermaid(); // A Mermaid flowchart.The workflow interpreter uses the bundled TypeScript port of
darkcore. Tasks and combinators compile
into tagged effects, which EffectTree interprets through a handler map. The
runtime lives in src/darkcore/ and is re-exported through src/darkcore.ts.
Handler maps let you add execution policies such as auditing or retry logic without changing workflow definitions. Dry runs use the same effect structure with different handlers.
| Area | Exports |
|---|---|
| State | Focus, Lay, Root, State, Flow, berylx |
| Composition | Task, AsyncTask, Sequence, Parallel, When, Else, Branch, Catch, Rescue, Workflow |
| Results | Ok, Err, ResultOps, BerylxError |
| Merge reducers | Merge.strict, Merge.deep, Merge.keepLeft, Merge.keepRight |
| Execution | EffectTree, Perform, Darkcore |
| Graphs | Graph, Graph.toDot(), Graph.toMermaid() |
| Compatibility | attachRoot, fromCrayResult, toCrayResult, Cray, CraySuccess, CrayFailure |
| Helpers | run(workflow, focus), task(name, block) |
Darkcore also exports Maybe, Either, Result, State, Validation, and
IOEffects with VirtualWorld. Its Ok and Err types are separate from
Berylx's workflow result types.
Ruby operators map to methods in TypeScript:
| Ruby | TypeScript | Purpose |
|---|---|---|
a >> b |
a.then(b) |
Sequence |
a & b |
a.par(b) |
Parallel composition |
root | workflow |
root.pipe(workflow) |
Execute and commit |
state | task |
state.pipe(task) |
Execute in state space |
state & task |
state.and(task) |
Accumulate a node in a new State |
When[:name] { ... } |
When.of('name', predicate) |
Define a condition |
arm | Else |
arm.or(Else.then(task)) |
Add a fallback arm |
See MIGRATION.md for the migration plan from the
@minamorl/cray and @minamorl/lay packages in root-paradigm, including
differences in state updates, asynchronous execution, and errors.
git clone https://github.com/minamorl/berylx-ts.git
cd berylx-ts
pnpm install
pnpm run typecheck
pnpm run build
pnpm testThe build writes JavaScript and declarations to dist/. pnpm test runs Vitest
and the positive and negative TypeScript checks in scripts/check-types.mjs.
Use pnpm run test:watch for Vitest watch mode or pnpm run check:types to run
only the type checks.
MIT.