Effect notes: flow and cancellation
Another day, another batch of refactoring Val Town internals to Effect. Today has been pretty good: haven't hit many bugs, and the docs have been helpful.
flow
I had a few methods in this codebase for which, in TypeScript before the refactor, we tolerated failure. If the Promise rejected, we'd send it to Sentry with Sentry.captureException and keep going. I found a way to do this by combining Effect.tapError (to run a side-effect on errors) and Effect.ignore (to swallow the error and avoid short-circuiting when it happens), but I wanted to combine them. My naive solution was to use pipe(), but pipe() expects a value as its first argument, so a pipe would look like
const add = x => x + 1;
const subtract = x => x - 1;
const piped = pipe(2, add, subtract);
// Same as
subtract(add(2))But instead I wanted something that yielded a function. So flow does that:
const add = x => x + 1;
const subtract = x => x - 1;
const flowed = flowed(add, subtract);
// Same as
x => subtract(add(x))Works great! Here's how it looked in this specific case:
/**
* If we have an operation that should be able to fail without short-circuiting,
* but we want to log the error to Sentry, we use this.
*/
function tapAndIgnore(message: string) {
// NOTE: flow intead of pipe here because pipe expects the first Effect
// given to be something that produces a value.
return flow(
Effect.tapError((error) =>
Effect.sync(() =>
Sentry.captureException(new Error(message, { cause: error }))
)
),
Effect.ignore
);
}Then I can pipe an effect into this:
// Some Effect
.pipe(tapAndIgnore('Lockfile generation failed'))Cancellation
There are a bunch of driving factors for my dive into Effect. For reference, we've been using neverthrow, and while it's been pretty great for business logic, it hasn't been able to help much with the messy internals of Val Town, which is where a lot of the complexity lies. Some of the stuff that drew me to Effect:
- The generator syntax is nice and terse.
- Platforms make working with Node APIs nicer.
- Observability & scheduling are really nice to have at this level. Using the OpenTelemetry SDK directly is not fun.
- It has an idea of cancellation.
The last one is pretty interesting: basically if you have a function in JavaScript like:
async function someTask() {
const a = await getA();
const b = await getB(a);
const c = await getC(b);
return c;
}And you want to limit the amount of time this function has to run, there is no good way. p-timeout was my old solution, but that only throws an error and ignores the value of the function: the function still runs. AbortSignal is great and gets us a lot further, but an implementation with AbortSignal that allows this method to be fully interruptible would look like:
async function someTask(signal) {
signal.throwIfAborted();
const a = await getA();
signal.throwIfAborted();
const b = await getB(a);
signal.throwIfAborted();
const c = await getC(b);
return c;
}This is obviously verbose and not great. In contrast, Effects are interruptible by default, though you can mark them as not interruptible. The Effect version of this would be just
async function someTask(signal) {
return Effect.gen(function* () {
const a = yield* getA();
const b = yield* getB(a);
const c = yield* getC(b);
return c;
})
}Pretty nifty. One of the big realizations I've had recently is that all async work should be bound both in parallelism and time. In other words: using Promise.all and running an arbitrary number of async tasks at the same time is bad by default because you'll eventually run out of some resource like ports or file descriptors, and most async operations like database queries or network requests should have a timeout by default. Otherwise you always eventually get bad behavior.
Effect plays into this pretty nicely: it makes logic cancellable and easy to bound with timeouts.
Thanks for reading! Previously in Effect devlogs: