DEV Community

Cover image for Building an Effect Runtime in TypeScript - Part 2: Integrating the JavaScript Ecosystem Like LEGO Blocks
Augusto Vivaldelli
Augusto Vivaldelli

Posted on

Building an Effect Runtime in TypeScript - Part 2: Integrating the JavaScript Ecosystem Like LEGO Blocks

Continuation of Part 1_

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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!")
);
Enter fullscreen mode Exit fullscreen mode

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 });
      }
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Now you can do:

const program = asyncFlatMap(
  readFile('config.json'),
  (contents) => {
    const config = JSON.parse(contents);
    return asyncSucceed(config);
  }
);
Enter fullscreen mode Exit fullscreen mode

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 });
      }
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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 }));
  });
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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();
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

This code:

  1. Tries to fetch the primary endpoint with a timeout
  2. After 200ms, launches the fallback fetch (also with a timeout)
  3. The first one to respond wins
  4. The loser is canceled automatically via AbortController
  5. 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);
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

Example with DOM clicks:

const waitForClick = fromEvent<MouseEvent>(
  document.getElementById('button'),
  'click'
);

const program = asyncFlatMap(
  waitForClick,
  (event) => asyncSucceed(`Click at: ${event.clientX}, ${event.clientY}`)
);
Enter fullscreen mode Exit fullscreen mode

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());
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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')
);
Enter fullscreen mode Exit fullscreen mode

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])
);
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

The pattern is always the same:

  1. Identify the connection/setup operation
  2. Wrap it with fromPromise or fromCallback
  3. Use acquireRelease to guarantee cleanup
  4. 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
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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);
    }
  ));
}
Enter fullscreen mode Exit fullscreen mode

This code:

  1. ✅ Opens a PostgreSQL connection pool
  2. ✅ Starts a transaction
  3. ✅ Downloads multiple URLs in parallel (with AbortController)
  4. ✅ Processes each response
  5. ✅ Saves to the database
  6. ✅ If one fails → cancels all the others
  7. ✅ If one times out (30s) → cancels all
  8. ✅ If everything is OK → COMMIT
  9. ✅ If there’s an error → ROLLBACK
  10. ✅ 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

Problems with this version:

  1. ❌ The timeout doesn’t automatically cancel the fetch
  2. ❌ If Promise.all fails, we still track controllers even for requests that already finished
  3. ❌ Error handling is manual and bug-prone
  4. ❌ If pool.end() fails in finally, we lose the original error
  5. ❌ 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...
Enter fullscreen mode Exit fullscreen mode

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(/* ... */);
  });
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Take existing APIs (fetch, fs, axios, pg, redis, etc.)
  2. Wrap them with simple primitives (fromPromise, fromCallback, fromPromiseAbortable)
  3. Get all the runtime benefits (cancellation, resources, composition)
  4. 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:

  1. Identify the main operation: is it callback-based, Promise-based, or event-based?

  2. Choose the right primitive:

    • Node-style callbacks → fromCallback
    • Promises → fromPromise
    • Promises with cancellation → fromPromiseAbortable
    • Events → fromEvent
    • Streams → fromNodeStream or a custom ZStream
  3. Wrap the connection/setup: if the library has a setup operation (connect to DB, open file, etc.), use acquireRelease

  4. Wrap the individual operations: each method that returns data also gets wrapped with the appropriate primitive

  5. Test cancellation: verify that interrupting a fiber cleans up resources correctly

  6. Document the integration: explain what guarantees it provides (cancellation, resource safety, etc.)


24. The future: an ecosystem of integrations

Imagine an ecosystem where:

  • brass-node exposes all of Node’s stdlib as effects
  • brass-web exposes Web APIs (fetch, WebSocket, IndexedDB)
  • brass-pg, brass-redis, brass-mongo for databases
  • brass-express, brass-fastify for HTTP servers
  • brass-aws, brass-gcp for 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()
Enter fullscreen mode Exit fullscreen mode

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:

  1. Pick a library you use frequently
  2. Identify whether it uses callbacks, Promises, or events
  3. Create bridges using the primitives we covered
  4. Test that cancellation and cleanup work
  5. 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)