Brass TS — Building an Effect Runtime in TypeScript (Part 4)

Published: (January 10, 2026 at 10:23 AM EST)
3 min read
Source: Dev.to

Source: Dev.to

TL;DR

brass-http is not a fetch wrapper.
It’s an effectful HTTP client built on top of a runtime with fibers, structured concurrency, and cancellation.

In the previous parts we built the foundations of an effect runtime inspired by ZIO:

  • fibers and scheduling
  • structured concurrency
  • integration with the JavaScript ecosystem
  • a ZIO‑style HTTP client with real DX

In this fourth part we show what changes once HTTP is modeled as an effect — and why features like retry, cancellation, streaming, and pipelines stop being hacks and become runtime properties.

📦 Repository:


Important mental shift

HTTP is not an async function.
HTTP is a description of work.

In Brass:

  • nothing executes until interpreted
  • retries are scheduled, not looped
  • cancellation is structural, not best‑effort

Callout:
Effects delay execution. Promises don’t.


Effect vs Promise

Promise‑based HTTP
fetch()
 └─▶ starts immediately
      └─▶ cannot be stopped
           └─▶ side‑effects escape

Effect‑based HTTP (Brass)
HTTP Effect

   │  (pure description)

Interpreter (toPromise / Fiber / Stream)


Runtime Scheduler


Execution

Creating an effectful HTTP client

const http = httpClient({
  baseUrl: "https://jsonplaceholder.typicode.com",
}).withRetry({
  maxRetries: 3,
  baseDelayMs: 200,
  maxDelayMs: 2000,
});

🚫 No request has been executed yet.
✅ You’ve only described:

  • how requests are built
  • how retries behave
  • how errors are classified
  • how everything integrates with the runtime scheduler

Running effects: toPromise

const effect = http.getJson("/posts/1");
const promise = toPromise(effect, {});
const result = await promise;

Callout:
getJson does not return a Promise. It returns an Effect.
toPromise is just one interpreter.


Retry as a first‑class concept

const http = httpClient({ baseUrl }).withRetry({
  maxRetries: 3,
  retryOnStatus: (s) => s >= 500,
  retryOnError: (e) => e._tag === "FetchError",
});

This is not a wrapper.
Retry:

  • is not a while loop or try/catch + setTimeout
  • is not promise recursion

💡 Retry is part of the effect description.


Retry lifecycle

HTTP Effect


Attempt #1 ──┐
   │          │
   │ error    │ schedule
   ▼          │ delay
Retry Policy  │ (scheduler)
   │          │
   ▼          │
Attempt #2 ──┘


Attempt #N


Success / Failure

Callout:
Retry is scheduled, not looped.


Cancellation that actually works

Canceling an effect cancels everything.
Thanks to fibers and structured concurrency, canceling a single fiber propagates through the entire HTTP lifecycle.

Fiber

 ├─▶ HTTP Effect
 │     ├─▶ fetch
 │     ├─▶ retry delay
 │     └─▶ response decode

 └─▶ other effects

Cancel Fiber


╳ fetch aborted
╳ retry timers cleared
╳ decode stopped

Callout:
Cancellation is structural, not best‑effort.


Raw wire responses (full control)

const wire = await toPromise(http.get("/posts/1"), {});

console.log(wire.status);
console.log(wire.bodyText);

Callout:
Even raw wire responses are retryable, cancelable, and scheduled.


Requests are data (optics FTW)

const req = mergeHeaders({ accept: "application/json" })(
  setHeaderIfMissing("content-type", "application/json")({
    method: "POST",
    url: "/posts",
    body: JSON.stringify({
      userId: 1,
      title: "Hello Brass",
      body: "Testing POST from Brass HTTP client",
    }),
  })
);

const response = await http.request(req).toPromise({});
Base Request


mergeHeaders


setHeaderIfMissing


Final Request


http.request(effect)

Callout:
Requests are values, not actions.


Pipelines: HTTP as a flow of effects

Request


[ Enrich ]


[ Retry Policy ]


[ Fetch ]


[ Decode ]


Response / Wire

Streaming (design‑ready)

Producer (HTTP Body)


Stream Effect

   ├─▶ Consumer A

   └─▶ Consumer B

Cancel


Stream stops

Callout:
Streams are effects, not callbacks.


Why does this matter?

Promises:
  async + hope

Effects:
  describe → schedule → execute → control

Because once HTTP is an effect:

  • retry stops being fragile
  • cancellation becomes predictable
  • testing becomes trivial
  • composition becomes natural

What’s next

  • full streaming APIs
  • timeouts as effects
  • tracing
  • metrics
  • resource scopes
Back to Blog

Related posts

Read more »

Setup MonoRepo in Nest.js

Monorepos with Nest.js Monorepos are becoming the default choice for backend teams that manage more than one service or shared library. Nest.js works very well...