In the first part we explored the foundations of brass-runtime: effects, fibers, scopes, and structured concurrency. But one big question was left hanging:
How do we integrate the existing JavaScript/TypeScript ecosystem into this model?
The short answer: like LEGO blocks. And in this part I’m going to show you exactly how.
1. The integration problem (and why it matters)
JavaScript has decades of incredible libraries:
- Browser APIs (
fetch,setTimeout,WebSocket) - Node.js APIs (
fs,net,child_process) - Third‑party libraries (axios, sqlite3, redis, etc.)
All of these use different conventions:
- Node-style callbacks (
(err, result) => ...) - Promises
- Event emitters
- AbortController for cancellation
- Node streams
If our runtime can’t integrate these things easily, it’s useless. Nobody is going to rewrite everything from scratch.
The good news: with the right primitives, integrating anything in the JavaScript ecosystem is trivial.
2. The magic primitive: async
Let’s remember the definition of the Async type from Part 1:
type Async<R, E, A> =
| { _tag: "Async"; register: (env: R, cb: (exit: Exit<E, A>) => void) => void }
| // ... other cases
The Async case is the bridge between JavaScript’s callback-based world and our runtime. The signature is simple:
function async<R, E, A>(
register: (env: R, cb: (exit: Exit<E, A>) => void) => void
): Async<R, E, A>
Translated: “give me a function that registers a callback, and I’ll give you an Async the runtime can run.”
Let’s see how to use it.
3. Integrating setTimeout: the integration “Hello World”
function sleep(ms: number): Async<{}, never, void> {
return async((env, cb) => {
setTimeout(() => {
cb({ _tag: "Success", value: undefined });
}, ms);
});
}
That’s it. Now sleep is a first-class effect that:
- can be composed with
flatMap,map,zip - respects cancellation (more on this soon)
- can be used in
race,zipPar, etc.
const program = asyncFlatMap(
sleep(1000),
() => asyncSucceed("I'm awake!")
);
4. Integrating Node-style callbacks: fs.readFile
Node.js uses the (err, result) => ... pattern for everything:
function readFile(path: string): Async<{}, Error, string> {
return async((env, cb) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) {
cb({ _tag: "Failure", error: err });
} else {
cb({ _tag: "Success", value: data });
}
});
});
}
Now you can do:
const program = asyncFlatMap(
readFile('config.json'),
(contents) => {
const config = JSON.parse(contents);
return asyncSucceed(config);
}
);
And it composes with everything else. If the scope closes before the operation finishes, it’s canceled gracefully.
5. The generic pattern: fromCallback
We can abstract the previous pattern into a reusable function:
function fromCallback<A>(
f: (cb: (err: Error | null, result: A) => void) => void
): Async<{}, Error, A> {
return async((env, cb) => {
f((err, result) => {
if (err) {
cb({ _tag: "Failure", error: err });
} else {
cb({ _tag: "Success", value: result });
}
});
});
}
Now any Node.js API integrates in one line:
const readFile = (path: string) =>
fromCallback(cb => fs.readFile(path, 'utf8', cb));
const writeFile = (path: string, data: string) =>
fromCallback(cb => fs.writeFile(path, data, cb));
const execCommand = (cmd: string) =>
fromCallback(cb => exec(cmd, cb));
Three different APIs, one common primitive. LEGO.
6. Integrating Promises: fromPromise
Modern JavaScript is full of Promises. Integrating them is just as simple:
function fromPromise<A>(thunk: () => Promise<A>): Async<{}, unknown, A> {
return async((env, cb) => {
thunk()
.then(value => cb({ _tag: "Success", value }))
.catch(error => cb({ _tag: "Failure", error }));
});
}
Important: we take a thunk (a function that returns the Promise), not the Promise directly. This preserves the lazy semantics of our effects.
Examples:
// fetch API
const getData = (url: string) =>
fromPromise(() => fetch(url).then(r => r.json()));
// axios
const post = (url: string, data: any) =>
fromPromise(() => axios.post(url, data));
// any Promise-based library
const query = (sql: string) =>
fromPromise(() => db.query(sql));
7. The cancellation problem with Promises
Here’s where it gets interesting. Promises can’t be canceled in standard JavaScript. But many modern APIs support AbortController:
fetch(url, { signal: abortController.signal })
If we call abortController.abort(), the operation is canceled.
Our runtime needs to integrate this.
8. fromPromiseAbortable: the definitive pattern
This is the crown jewel:
function fromPromiseAbortable<A>(
f: (signal: AbortSignal) => Promise<A>
): Async<{}, unknown, A> {
return async((env, cb) => {
const abortController = new AbortController();
f(abortController.signal)
.then(value => cb({ _tag: "Success", value }))
.catch(error => {
if (error.name === 'AbortError') {
cb({ _tag: "Failure", error: { _tag: "Interrupted" } });
} else {
cb({ _tag: "Failure", error });
}
});
// Register cleanup for cancellation
return () => abortController.abort();
});
}
The key point: the register function can return a finalizer, a function the runtime will call if the fiber is interrupted.
Now we can integrate modern APIs with full cancellation:
const fetchCancelable = (url: string) =>
fromPromiseAbortable(signal => fetch(url, { signal }));
const program = race(
fetchCancelable('https://slow-api.com/data'),
fetchCancelable('https://fast-api.com/data'),
scope
);
// The losing API call is canceled automatically
See what we just did? We took an API that already supports cancellation (fetch + AbortController) and plugged it into our structured concurrency model. Automatically we get:
- cancellation when the scope closes
- cleanup when we lose a
race - interruption when the parent fiber is canceled
All without modifying fetch or writing manual cleanup logic.
9. Real example: multiple sources with timeout and fallback
Let’s see a real use case that combines several patterns:
function getDataWithFallback(
primaryUrl: string,
fallbackUrl: string,
timeoutMs: number,
scope: Scope<{}>
): Async<{}, Error, ApiData> {
// Fetch with AbortController integrated
const fetchWithTimeout = (url: string) =>
timeout(
fromPromiseAbortable(signal =>
fetch(url, { signal }).then(r => r.json())
),
timeoutMs,
scope
);
// Race between primary and fallback
const primaryAttempt = fetchWithTimeout(primaryUrl);
const fallbackAttempt = asyncFlatMap(
sleep(200), // give the primary a head start
() => fetchWithTimeout(fallbackUrl)
);
return race(primaryAttempt, fallbackAttempt, scope);
}
This code:
- Tries to fetch the primary endpoint with a timeout
- After 200ms, launches the fallback fetch (also with a timeout)
- The first one to respond wins
- The loser is canceled automatically via
AbortController - If both fail or time out, the error propagates
All in ~15 lines, with correct cancellation, no memory leaks, and clean composition.
10. Integrating Event Emitters: fromEvent
Node.js and the browser use event emitters for everything (streams, sockets, DOM events). Integrating them is another common pattern:
function fromEvent<A>(
target: EventTarget | EventEmitter,
eventName: string
): Async<{}, never, A> {
return async((env, cb) => {
const handler = (event: A) => {
cb({ _tag: "Success", value: event });
};
target.addEventListener(eventName, handler);
// Cleanup: remove the listener
return () => {
target.removeEventListener(eventName, handler);
};
});
}
Example with DOM clicks:
const waitForClick = fromEvent<MouseEvent>(
document.getElementById('button'),
'click'
);
const program = asyncFlatMap(
waitForClick,
(event) => asyncSucceed(`Click at: ${event.clientX}, ${event.clientY}`)
);
If we cancel the effect before the click, the event listener is removed automatically. No more orphaned listeners.
11. Node.js streams: the trickiest case
Node streams (fs.createReadStream, sockets, etc.) are notoriously hard to handle correctly. Let’s see how to integrate them:
function fromNodeStream<A>(
createStream: () => Readable
): ZStream<{}, Error, A> {
return {
open: (scope) => {
return acquireRelease(
// Acquire: create and configure the stream
asyncSync(() => {
const stream = createStream();
const queue = new AsyncQueue<Exit<Option<Error>, A>>();
stream.on('data', (chunk: A) => {
queue.enqueue({ _tag: "Success", value: chunk });
});
stream.on('error', (err: Error) => {
queue.enqueue({ _tag: "Failure", error: Some(err) });
});
stream.on('end', () => {
queue.enqueue({ _tag: "Failure", error: None }); // EOF
});
return { stream, queue };
}),
// Release: destroy the stream
(resource, exit) => asyncSync(() => {
resource.stream.destroy();
}),
scope
).pipe(resource => resource.queue.dequeue());
}
};
}
Now you can do:
const file = fromNodeStream(() =>
fs.createReadStream('data.csv')
);
const processed = file
.pipe(map(chunk => chunk.toString()))
.pipe(filter(line => line.length > 0))
.pipe(map(line => parseCSV(line)));
runCollect(processed, {})(env, exit => {
console.log('Processed data:', exit);
scope.close(exit);
});
The Node stream integrates perfectly into our ZStream model, with:
- automatic backpressure
- guaranteed cleanup (
.destroy()is always called) - composition with other streams
12. Third-party libraries: Redis, PostgreSQL, MongoDB
The pattern repeats. Here are quick examples:
Redis
import { createClient } from 'redis';
function connectRedis(url: string): Async<{}, Error, RedisClient> {
return fromPromise(() =>
createClient({ url }).connect()
);
}
function redisGet(client: RedisClient, key: string): Async<{}, Error, string | null> {
return fromPromise(() => client.get(key));
}
function redisSet(client: RedisClient, key: string, value: string): Async<{}, Error, void> {
return fromPromise(() => client.set(key, value));
}
// Use with resource safety
const program = acquireRelease(
connectRedis('redis://localhost'),
(client, exit) => fromPromise(() => client.quit()),
scope
).pipe(client =>
redisSet(client, 'user:123', 'Alice')
);
PostgreSQL with pg
import { Pool } from 'pg';
function createPool(config: PoolConfig): Async<{}, Error, Pool> {
return asyncSync(() => new Pool(config));
}
function query<T>(pool: Pool, sql: string, params: any[]): Async<{}, Error, T[]> {
return fromPromise(() =>
pool.query(sql, params).then(r => r.rows)
);
}
const program = acquireRelease(
createPool({ host: 'localhost', database: 'mydb' }),
(pool, exit) => fromPromise(() => pool.end()),
scope
).pipe(pool =>
query(pool, 'SELECT * FROM users WHERE id = $1', [123])
);
MongoDB
import { MongoClient } from 'mongodb';
function connectMongo(url: string): Async<{}, Error, MongoClient> {
return fromPromise(() => MongoClient.connect(url));
}
function find<T>(
collection: Collection,
query: any
): Async<{}, Error, T[]> {
return fromPromise(() => collection.find(query).toArray());
}
const program = acquireRelease(
connectMongo('mongodb://localhost'),
(client, exit) => fromPromise(() => client.close()),
scope
).pipe(client => {
const db = client.db('mydb');
const users = db.collection('users');
return find(users, { active: true });
});
The pattern is always the same:
- Identify the connection/setup operation
- Wrap it with
fromPromiseorfromCallback - Use
acquireReleaseto guarantee cleanup - Wrap each individual operation with the appropriate primitive
13. Axios with full cancellation
Axios supports cancellation via CancelToken (deprecated) and AbortController (modern). Here’s the modern pattern:
import axios from 'axios';
function axiosGet<T>(url: string, config?: AxiosRequestConfig): Async<{}, Error, T> {
return fromPromiseAbortable(signal =>
axios.get<T>(url, { ...config, signal }).then(r => r.data)
);
}
function axiosPost<T, D>(
url: string,
data: D,
config?: AxiosRequestConfig
): Async<{}, Error, T> {
return fromPromiseAbortable(signal =>
axios.post<T>(url, data, { ...config, signal }).then(r => r.data)
);
}
// Use with timeout and race
const getUser = (id: number) =>
timeout(
axiosGet(`/api/users/${id}`),
5000,
scope
);
const program = race(
getUser(123),
getUser(456),
scope
);
// The losing request is canceled via AbortController
14. WebSockets: bidirectional events
WebSockets are more complex because they’re bidirectional. We need to handle incoming and outgoing messages:
function connectWebSocket(url: string): Async<{}, Error, WebSocket> {
return async((env, cb) => {
const ws = new WebSocket(url);
ws.onopen = () => cb({ _tag: "Success", value: ws });
ws.onerror = (err) => cb({ _tag: "Failure", error: new Error('WebSocket error') });
return () => ws.close();
});
}
function receiveMessages(ws: WebSocket): ZStream<{}, Error, string> {
return {
open: (scope) => {
const queue = new AsyncQueue<Exit<Option<Error>, string>>();
ws.onmessage = (event) => {
queue.enqueue({ _tag: "Success", value: event.data });
};
ws.onerror = (err) => {
queue.enqueue({ _tag: "Failure", error: Some(new Error('WS error')) });
};
ws.onclose = () => {
queue.enqueue({ _tag: "Failure", error: None }); // EOF
};
return queue.dequeue();
}
};
}
function sendMessage(ws: WebSocket, msg: string): Async<{}, Error, void> {
return asyncSync(() => ws.send(msg));
}
// Full usage
const program = acquireRelease(
connectWebSocket('wss://api.example.com/stream'),
(ws, exit) => asyncSync(() => ws.close()),
scope
).pipe(ws => {
const messages = receiveMessages(ws);
// Process incoming messages
return runForeach(messages, {}, msg => {
console.log('Received:', msg);
return sendMessage(ws, 'ACK');
});
});
15. The generic pattern for libraries: the “Bridge”
After integrating several libraries, I noticed a common pattern. We can abstract it:
// 1. Generic bridge for Promises
function bridgePromise<Args extends any[], R>(
f: (...args: Args) => Promise<R>
) {
return (...args: Args): Async<{}, unknown, R> =>
fromPromise(() => f(...args));
}
// 2. Bridge for Node-style callbacks
function bridgeCallback<Args extends any[], R>(
f: (...args: [...Args, (err: Error | null, result: R) => void]) => void
) {
return (...args: Args): Async<{}, Error, R> =>
fromCallback(cb => f(...args, cb));
}
// 3. Bridge for abortable Promises
function bridgeAbortable<Args extends any[], R>(
f: (...args: [...Args, AbortSignal]) => Promise<R>
) {
return (...args: Args): Async<{}, unknown, R> =>
fromPromiseAbortable(signal => f(...args, signal));
}
With these bridges, integrating an entire library is trivial:
// Example: full wrapper around fs/promises
import * as fsp from 'fs/promises';
export const FileSystem = {
readFile: bridgePromise(fsp.readFile),
writeFile: bridgePromise(fsp.writeFile),
mkdir: bridgePromise(fsp.mkdir),
readdir: bridgePromise(fsp.readdir),
stat: bridgePromise(fsp.stat),
// ... etc
};
// Direct usage
const program = FileSystem.readFile('config.json', 'utf8')
.pipe(content => JSON.parse(content))
.pipe(config => FileSystem.writeFile('backup.json', JSON.stringify(config)));
16. Building an integrations library: “brass-node”
With these patterns, we could create a separate package brass-node that exposes all of Node.js as effects:
// brass-node/fs.ts
export const FS = {
readFile: (path: string, encoding: string) =>
fromCallback(cb => fs.readFile(path, encoding, cb)),
writeFile: (path: string, data: string) =>
fromCallback(cb => fs.writeFile(path, data, cb)),
createReadStream: (path: string) =>
fromNodeStream(() => fs.createReadStream(path)),
// ... all operations
};
// brass-node/http.ts
export const HTTP = {
get: (url: string, options?: RequestOptions) =>
fromPromiseAbortable(signal =>
fetch(url, { ...options, signal })
),
// ... etc
};
// brass-node/child_process.ts
export const ChildProcess = {
exec: (command: string) =>
fromCallback(cb => cp.exec(command, cb)),
spawn: (command: string, args: string[]) =>
// returns a stream of events
};
Users of the library get all the power of Node.js with all the runtime guarantees:
- consistent cancellation
- resource safety
- clean composition
- structured concurrency
And all without learning a new API. It’s the same Node API, just wrapped.
17. The power of LEGO-style composition
Here’s where it all comes together. Let’s take a real problem:
“I want to download several files in parallel from URLs, process them, save the results to PostgreSQL, and if any fail or take longer than 30 seconds, cancel everything and roll back.”
With the LEGO blocks we built:
function processFiles(
urls: string[],
scope: Scope<{}>
): Async<{}, Error, void> {
return acquireRelease(
// Connect to the database
createPool({ host: 'localhost', database: 'mydb' }),
(pool, exit) => {
// If there was an error, rollback; if success, commit
const transactionEnd = exit._tag === "Success"
? query(pool, 'COMMIT', [])
: query(pool, 'ROLLBACK', []);
return asyncFlatMap(transactionEnd, () =>
fromPromise(() => pool.end())
);
},
scope
).pipe(pool => asyncFlatMap(
// Start transaction
query(pool, 'BEGIN', []),
() => {
// Download and process files in parallel
const downloads = urls.map(url =>
timeout(
asyncFlatMap(
fromPromiseAbortable(signal => fetch(url, { signal })),
response => fromPromise(() => response.text())
).pipe(contents => {
const processed = processContent(contents);
// Save to DB
return query(pool,
'INSERT INTO files (url, contents) VALUES ($1, $2)',
[url, processed]
);
}),
30000, // 30s timeout
scope
)
);
// Run everything in parallel
return collectAllPar(downloads, scope);
}
));
}
This code:
- ✅ Opens a PostgreSQL connection pool
- ✅ Starts a transaction
- ✅ Downloads multiple URLs in parallel (with AbortController)
- ✅ Processes each response
- ✅ Saves to the database
- ✅ If one fails → cancels all the others
- ✅ If one times out (30s) → cancels all
- ✅ If everything is OK → COMMIT
- ✅ If there’s an error → ROLLBACK
- ✅ Always closes the pool at the end
All without writing a single try/catch, without manual Promise.all, without tracking AbortControllers.
The LEGO blocks handled everything.
18. Comparison: the same problem without the runtime
To appreciate the power of composition, here’s how this would look with traditional Promises and async/await:
async function processFilesTraditional(urls: string[]) {
const pool = new Pool({ host: 'localhost', database: 'mydb' });
const controllers: AbortController[] = [];
try {
await pool.query('BEGIN');
const downloads = urls.map(url => {
const controller = new AbortController();
controllers.push(controller);
return Promise.race([
fetch(url, { signal: controller.signal })
.then(r => r.text())
.then(contents => {
const processed = processContent(contents);
return pool.query(
'INSERT INTO files (url, contents) VALUES ($1, $2)',
[url, processed]
);
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 30000)
)
]);
});
await Promise.all(downloads);
await pool.query('COMMIT');
} catch (error) {
// Cancel all pending fetch calls
controllers.forEach(c => c.abort());
try {
await pool.query('ROLLBACK');
} catch (rollbackError) {
// What do we do here?
console.error('Rollback error:', rollbackError);
}
throw error;
} finally {
await pool.end();
}
}
Problems with this version:
- ❌ The timeout doesn’t automatically cancel the fetch
- ❌ If
Promise.allfails, we still track controllers even for requests that already finished - ❌ Error handling is manual and bug-prone
- ❌ If
pool.end()fails infinally, we lose the original error - ❌ It’s not composable: you can’t easily reuse parts of it
With the runtime, all these edge cases are handled by scopes and finalizers.
19. Extending the ecosystem: third-party packages
The beauty of the system is that anyone can create bridges for their favorite libraries:
// brass-axios/index.ts
import axios from 'axios';
import { fromPromiseAbortable } from 'brass-runtime';
export function createAxiosClient(baseURL: string) {
const instance = axios.create({ baseURL });
return {
get: <T>(url: string, config?: AxiosRequestConfig) =>
fromPromiseAbortable(signal =>
instance.get<T>(url, { ...config, signal }).then(r => r.data)
),
post: <T, D>(url: string, data: D, config?: AxiosRequestConfig) =>
fromPromiseAbortable(signal =>
instance.post<T>(url, data, { ...config, signal }).then(r => r.data)
),
// ... put, delete, patch, etc
};
}
// brass-redis/index.ts
// brass-mongoose/index.ts
// brass-express/index.ts
// etc...
Each package exposes the familiar library API, but with the runtime’s superpowers.
20. Testing: easily mockable integrations
Another advantage: since everything is expressed in terms of Async, mocking integrations for tests is trivial:
// Production
const FileSystem = {
readFile: (path: string) => fromCallback(cb => fs.readFile(path, 'utf8', cb))
};
// Test
const FileSystemMock = {
readFile: (path: string) => {
if (path === 'test.txt') {
return asyncSucceed('mocked contents');
} else {
return asyncFail(new Error('File not found'));
}
}
};
// The code that uses FileSystem doesn't need to change
function myProgram(fs: typeof FileSystem) {
return fs.readFile('test.txt')
.pipe(contents => processContent(contents));
}
// Test
describe('myProgram', () => {
it('processes the file correctly', async () => {
const result = await runAsync(myProgram(FileSystemMock), {});
expect(result).toEqual(/* ... */);
});
});
No more complicated mocks with jest.mock(). You just swap the implementation with pure effects.
21. The circle closes: from JavaScript to brass and back
The beautiful irony is that the runtime doesn’t pull you away from the JavaScript ecosystem—it gives you a better way to use it:
- Take existing APIs (fetch, fs, axios, pg, redis, etc.)
- Wrap them with simple primitives (
fromPromise,fromCallback,fromPromiseAbortable) - Get all the runtime benefits (cancellation, resources, composition)
- When you need to interop with traditional code, use
runAsync
It’s a superset of the ecosystem, not a replacement.
22. Lessons about integration
After integrating dozens of APIs and libraries, I learned a few lessons:
The key is the right primitive
With async, fromPromise, fromCallback, and fromPromiseAbortable, you can integrate 99% of the JavaScript ecosystem. You don’t need a complex framework or code generators.
Cancellation is the differentiator
APIs without cancellation (setTimeout, basic callbacks) integrate fine, but they lose the runtime’s superpower. APIs with cancellation (fetch + AbortController, axios, WebSockets) integrate perfectly.
Resource management is free
Once you wrap something with acquireRelease, you get automatic cleanup. Whether it’s a file, a DB connection, a WebSocket, or a child process.
Composition > re-implementation
You don’t need to rewrite fs, axios, or pg. You just wrap them. Users keep the familiar API but gain runtime guarantees.
23. Building your own integration: checklist
If you want to integrate a new library into the runtime, follow these steps:
Identify the main operation: is it callback-based, Promise-based, or event-based?
-
Choose the right primitive:
- Node-style callbacks →
fromCallback - Promises →
fromPromise - Promises with cancellation →
fromPromiseAbortable - Events →
fromEvent - Streams →
fromNodeStreamor a customZStream
- Node-style callbacks →
Wrap the connection/setup: if the library has a setup operation (connect to DB, open file, etc.), use
acquireReleaseWrap the individual operations: each method that returns data also gets wrapped with the appropriate primitive
Test cancellation: verify that interrupting a fiber cleans up resources correctly
Document the integration: explain what guarantees it provides (cancellation, resource safety, etc.)
24. The future: an ecosystem of integrations
Imagine an ecosystem where:
-
brass-nodeexposes all of Node’s stdlib as effects -
brass-webexposes Web APIs (fetch, WebSocket, IndexedDB) -
brass-pg,brass-redis,brass-mongofor databases -
brass-express,brass-fastifyfor HTTP servers -
brass-aws,brass-gcpfor cloud providers
Each package would be small (just bridges), but together they’d cover the whole ecosystem.
And the best part: the primitives to build it already exist. It only takes time and community.
25. Complete example: HTTP server with a database
To close out, here’s an example that integrates multiple pieces:
import express from 'express';
import { Pool } from 'pg';
// Wrapper for Express
function createServer(port: number): Async<{}, Error, express.Application> {
return asyncSync(() => {
const app = express();
app.listen(port);
return app;
});
}
// Route handler as an effect
function handlerGetUser(pool: Pool, userId: string): Async<{}, Error, User> {
return timeout(
query(pool, 'SELECT * FROM users WHERE id = $1', [userId])
.pipe(rows => {
if (rows.length === 0) {
return asyncFail(new Error('User not found'));
}
return asyncSucceed(rows[0]);
}),
5000, // 5-second timeout
scope
);
}
// Full server
function server(scope: Scope<{}>): Async<{}, Error, void> {
return acquireRelease(
// Setup: DB and server
asyncFlatMap(
createPool({ host: 'localhost', database: 'mydb' }),
pool => asyncFlatMap(
createServer(3000),
app => asyncSucceed({ pool, app })
)
),
// Cleanup
({ pool, app }, exit) => fromPromise(() => pool.end()),
scope
).pipe(({ pool, app }) => {
// Define routes
app.get('/users/:id', (req, res) => {
const userId = req.params.id;
// Run the effect in an isolated scope
const handlerScope = scope.subScope();
handlerGetUser(pool, userId)(
{},
exit => {
handlerScope.close(exit);
if (exit._tag === "Success") {
res.json(exit.value);
} else {
res.status(500).json({ error: exit.error.message });
}
}
);
});
console.log('Server listening on port 3000');
// Wait forever (the scope is closed from the outside)
return asyncNever();
});
}
// Run
const scope = new Scope<{}>();
server(scope)({}, exit => {
console.log('Server finished:', exit);
});
// To shut down: scope.close()
This server:
- ✅ Manages DB connections correctly
- ✅ Each request has its own sub-scope
- ✅ Timeouts work per request
- ✅ On shutdown, all in-flight requests are canceled cleanly
- ✅ The DB pool closes correctly
All by integrating Express and pg—standard ecosystem libraries.
26. Final reflection: LEGO vs. Monolith
The difference between brass-runtime and other approaches:
Monolithic approach: “Use our HTTP client, our DB adapter, our way of doing EVERYTHING”
LEGO approach: “Use what already exists in JavaScript. We just give you the pieces to assemble it better”
The second approach has huge advantages:
- No lock-in
- No waiting for a framework to support your favorite library
- Lower learning curve (you keep using familiar APIs)
- The entire JavaScript ecosystem is available
And the magic is that the integration primitives (fromPromise, fromCallback, etc.) are trivial to implement but infinitely composable.
27. Your turn: build bridges
Now that you understand the patterns, here’s a challenge:
- Pick a library you use frequently
- Identify whether it uses callbacks, Promises, or events
- Create bridges using the primitives we covered
- Test that cancellation and cleanup work
- Share the integration!
The brass-runtime repo is waiting for integration contributions. Each new integrated library makes the ecosystem more powerful.
28. Resources and next steps
Runtime code: https://github.com/BaldrVivaldelli/brass-runtime
NPM package: https://www.npmjs.com/package/brass-runtime
Part 1 of the article: Building an Effect Runtime in TypeScript
If you’re interested, also check out:
- Effect-TS: an alternative runtime with extensive integrations
- ZIO: the original in Scala
- AbortController API docs: to understand cancellation in modern JavaScript
Conclusion
brass-runtime doesn’t ask you to abandon the JavaScript ecosystem. It asks you to use it better.
Each API you integrate becomes a LEGO block:
- composable with other blocks
- cancelable consistently
- with resources that clean themselves up
- testable without complex mocks
And the wildest part: the original library doesn’t need to know anything about the runtime. Axios doesn’t know about brass-runtime, but it integrates perfectly. PostgreSQL doesn’t know about fibers, but it works with scopes. Fetch doesn’t know about structured concurrency, but it cancels cleanly with AbortController.
Next time you have to:
- make multiple requests with timeout and fallback
- process a data stream with guaranteed cleanup
- manage WebSockets with elegant cancellation
- coordinate DB operations with transactions
Think LEGO. Think about how simple primitives assemble into powerful solutions.
And if you build something with brass-runtime, tell me. I’d love to see what integrations you create.
Questions? Integrations you’d like to see? Found a tricky edge case? Leave a comment or open an issue on GitHub.
Tags: #typescript #javascript #nodejs #async #promises #integration #architecture #composition #lego #ecosystem

Top comments (0)