Brass TS — Building an Effect Runtime in TypeScript (Part 4)
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:
getJsondoes not return aPromise. It returns anEffect.
toPromiseis 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
whileloop ortry/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