DEV Community

Cover image for Building an Effect Runtime in TypeScript: My little detour into Fibers and Structured Concurrency
Augusto Vivaldelli
Augusto Vivaldelli

Posted on

Building an Effect Runtime in TypeScript: My little detour into Fibers and Structured Concurrency

Status: experiment / learning project.
This post is a personal write-up of what I built in brass-runtime (repo name) and what I call brass-ts in the README: a small effect runtime inspired by ideas from ZIO 2, but written in plain TypeScript.


TL;DR

I built brass-runtime to see how far I could push a tiny, explicit effect runtime in TypeScript without making Promises the center of the universe. The project explores:

  • a sync, deterministic core (Effect),
  • an async ADT (Async) interpreted by a runtime,
  • a cooperative scheduler,
  • fibers with join, interrupt, and LIFO finalizers,
  • structured concurrency via Scope,
  • resource safety with acquireRelease,
  • and an early ZStream-like foundation with backpressure.

This is not production-ready software. It’s a microscope for runtime mechanics.


1. Why I went down this rabbit hole

TypeScript is a joy with Promise and async/await. But once you try to reason about:

  • consistent cancellation,
  • strict lifetimes for concurrent tasks,
  • deterministic cleanup of resources,
  • and composable concurrency operators,

you start noticing how much of the model is “implied” by the platform rather than defined by you.

At some point I couldn’t stop thinking about this question:

What if Promises weren’t the semantic core, but just an integration detail — and the runtime owned scheduling, cancellation, and cleanup as first-class concepts?

So I started a small experiment.


2. The guiding idea

What I wanted to test wasn’t “can I re-implement ZIO in TS?” — clearly not.

It was closer to:

  • can I make the semantics visible,
  • keep the building blocks small,
  • and see which guarantees actually require a sophisticated ecosystem vs. just a good model + interpreter?

3. Thinking in layers

The project is easier to explain as a stack:

  1. a minimal sync Effect core,
  2. an Async ADT,
  3. runtime + Scheduler,
  4. Fiber,
  5. Scope for structured concurrency and finalizers,
  6. a tiny ZStream-like shape.

I liked this approach because each layer adds power without turning the foundation into a black box.


4. A tiny sync Effect core

The README frames the core like this:

type Exit<E, A> =
  | { _tag: "Success"; value: A }
  | { _tag: "Failure"; error: E };

type Effect<R, E, A> = (env: R) => Exit<E, A>;
Enter fullscreen mode Exit fullscreen mode

I purposely started with a fully synchronous model.

It’s surprisingly clarifying: you can focus on composition, error semantics, and laws before introducing the chaos of asynchrony.


5. Async as an ADT (not as a Promise-first world)

Instead of leaning on Promises as the fundamental execution unit, I modeled asynchrony explicitly:

type Async<R, E, A> =
  | { _tag: "Succeed"; value: A }
  | { _tag: "Fail"; error: E }
  | { _tag: "Sync"; thunk: (env: R) => A }
  | { _tag: "Async"; register: (env: R, cb: (exit: Exit<E, A>) => void) => void }
  | { _tag: "FlatMap"; first: Async<R, E, any>; andThen: (a: any) => Async<R, E, A> };
Enter fullscreen mode Exit fullscreen mode

This might look like extra ceremony, but it gives the runtime room to define:

  • how fibers suspend/resume,
  • how cancellation propagates,
  • and how scheduling stays fair.

6. A cooperative scheduler

The surface area is intentionally small:

class Scheduler {
  schedule(task: () => void): void;
}
Enter fullscreen mode Exit fullscreen mode

The interesting part is the policy rather than the signature:

  • run work in small steps,
  • share progress across active fibers,
  • avoid starvation.

7. Fibers

Fibers are the execution units for Async:

type Fiber<E, A> = {
  id: number;
  status: () => "Running" | "Done" | "Interrupted";
  join: (cb: (exit: Exit<E | Interrupted, A>) => void) => void;
  interrupt: () => void;
  addFinalizer: (f: (exit: Exit<E | Interrupted, A>) => Async<any, any, any>) => void;
};
Enter fullscreen mode Exit fullscreen mode

The two things I most wanted to understand here were:

  1. how to make cancellation coherent,
  2. how to ensure finalizers still run under races and interruptions.

8. Scope: the “aha” piece

Scope is where the mental model really clicks for me:

class Scope<R> {
  fork<E, A>(eff: Async<R, E, A>, env: R): Fiber<E, A>;
  subScope(): Scope<R>;
  addFinalizer(f: (exit: Exit<any, any>) => Async<R, any, any>): void;
  close(exit?: Exit<any, any>): void;
  isClosed(): boolean;
}
Enter fullscreen mode Exit fullscreen mode

Once tasks and resources have a parent scope, it becomes obvious what “structured concurrency” should mean in practice.

Close the scope, and the runtime:

  • interrupts child fibers,
  • closes subscopes,
  • runs finalizers LIFO.

No ghosts. No forgotten cleanup.


9. Resource safety with acquireRelease

The classic pattern shows up in a stripped-down shape:

acquireRelease(
  acquire: Async<R, E, A>,
  release: (res: A, exit: Exit<any, any>) => Async<R, any, any>,
  scope: Scope<R>
): Async<R, E, A>;
Enter fullscreen mode Exit fullscreen mode

If acquire succeeds, the release action becomes a finalizer attached to the scope — which means it’s hard to forget and hard to skip.


10. Concurrency operators

With Async, Fiber, and Scope in place, I added a few operators that made the experiment feel complete:

  • race
  • zipPar
  • collectAllPar

The rule I tried to preserve is simple:

  • concurrent tasks should be born and die inside the same scope,
  • cancellation and errors should have a predictable path.

11. A tiny ZStream-like shape

Streams are still early, but the structure is there:

type Pull<R, E, A> = Async<R, Option<E>, A>;

type ZStream<R, E, A> = {
  open: (scope: Scope<R>) => Pull<R, E, A>;
};
Enter fullscreen mode Exit fullscreen mode

One pull, one element at most — a simple way to keep backpressure baked into the model.


12. The example that captures the spirit

This snippet from the README is the vibe of the whole project:

import {
  asyncTotal,
  asyncFlatMap,
  asyncSucceed,
} from "./asyncEffect";
import { sleep } from "./std";
import { Scope } from "./scope";
import { race } from "./concurrency";

type Env = {};

function task(name: string, ms: number) {
  return asyncFlatMap(sleep(ms), () =>
    asyncSucceed(`Terminé ${name}`)
  );
}

function main() {
  const env: Env = {};
  const scope = new Scope<Env>();
  const fast = task("rápida", 200);
  const slow = task("lenta", 1000);

  race(fast, slow, scope)(env, exit => {
    console.log("Resultado race:", exit);
    scope.close(exit);
  });
}

main();
Enter fullscreen mode Exit fullscreen mode

Narratively:

  1. create a scope,
  2. run two tasks inside it,
  3. race resolves with the fastest,
  4. the loser gets interrupted,
  5. the scope closes and cleanup happens.

13. The honest trade-offs

What this costs

  • More conceptual overhead than async/await.
  • A lot of runtime plumbing.
  • Concurrency testing is hard and easy to get wrong.

What it unlocks

  • A language for concurrency that’s explicit.
  • Cancellation and cleanup as part of the model.
  • A great learning playground.

14. What I learned

  • Cancellation is a semantic design problem before it’s an implementation problem.
  • Scopes dramatically simplify reasoning about lifetimes.
  • Keeping a sync core separate from Async lowers mental load.
  • A cooperative scheduler forces you to care about fairness early.

15. Where this could go

If I keep iterating, I’d probably focus on:

  • stronger, deterministic concurrency tests,
  • fiber tracing / debug tooling,
  • a richer, more ergonomic stream layer,
  • better typed wrappers over Node callback APIs.

16. Closing thoughts

brass-runtime is my way of making runtime semantics tangible in TypeScript.

It’s not trying to replace existing tools — it’s trying to make the hidden parts of concurrency and resource safety visible and discussable.

If you’re into this kind of stuff, I’d love feedback and criticism.

You can find more in my github https://github.com/BaldrVivaldelli/brass-runtime

Top comments (0)