effection
effection copied to clipboard
Document equivalent to `Promise.then`
In JS, it's common in a function that isn't async to chain promises together using .then and .catch
However, it's not clear what the effection equivalent to this is (what happens if you're in a function that isn't a generator? Do you just convert everything to promises using run(myGenerator).then((...) => { ... }) and escape effection temporarily)?
There were two suggestions by Charles on Discord for this:
- A simple approach that does not chain together well
function* then<A,B>(operation, fn: (value: A) => Operation<B>): Operation<B> {
return yield* fn(yield* operation);
}
- An approach that chains together well as long as you have a
pipefunction from somewhere (lodash, etc. or write your own)
function then<A,B>(fn: (value: A) => Operation<B>): (operation: Operation<A>) => Operation<B> {
return function*(operation) {
return yield* fn(yield* operation);
}
}
// usage
pipe(
operation,
then(lift((val) => val * 2)),
then(lift((val) => String(val)),
then(lift((val) => val.toUpperCase())),
)
Differences with Promise.then: typically you're allowed to chain non-async steps (ex: (async () => 5)().then(a => a+1)). Internally (assuming this is a Promise and not a promise-like), this can be implemented by checking if the return type of fn is instanceof Promise. One can do maybe achieve something similar by checking if fn has a generator symbol, but another approach is to just require using lift instead
- Another option is to leverage
Task
Note that effection already defines a Task interface that is both a Promise and an Operation. In this sense, we already have a path for implementing this pattern via run() which returns a Task. The problem though is that the implementation of then/catch/finally on the result of run() do NOT return a Task, but rather return just a Promise which means you can't chain it together.
If we instead had an implementation of Task that allowed chaining, this would also solve the problem nicely. Note that effect-ts, for example,
Other projects
Note that the effect-ts library has a similar design decision. They decided that their equivalent to tasks can only be piped, and that once you convert to a promise-like API, you cannot go back
import { Effect } from "effect"
const task1 = Effect
.succeed(1) // create a task
.pipe(Effect.delay("200 millis")); // okay to combine with pipes
Effect
.runPromise(task1) // convert to promise-like API
.then(console.log);
This is different from effection where there is no explicit runPromise, and rather conversion to a promise-like is done lazily if then is ever called
I thought about option (3), but it turns out it's trickier than I thought
The implementation for task.ts could look something like
then: async (...args) => {
return run(function*() {
return yield* call(() => getPromise().then(...args));
});
},
or, if you want to tie the lifetime to the parent, it could equivalently be expanded to
then: async (...args) => {
const newFrame = frame.createChild(function*() {
return yield* call(() => getPromise().then(...args));
});
newFrame.enter();
return newFrame.getTask();
},
however, this gets stuck in an infinite loop which I believe is caused by the .enter(), but I'm not fully sure since getting this to work feels like it requires going pretty deep into the inner workings of the continuation library. Maybe the solution is to do something similar to spawn instead?
@SebastienGllmt What about this as a potential solution that could be documented:
import { call, type Operation } from "effection";
export interface From<A> extends Operation<A> {
then<B>(fn: (value: A) => Operation<B>): From<B>;
catch<B>(fn: (error: unknown | Error) => Operation<B>): From<A | B>;
finally(fn: () => Operation<void>): From<A>;
}
export function from<T>(operation: Operation<T>): From<T> {
return {
[Symbol.iterator]: operation[Symbol.iterator],
then: (fn) =>
from(call(function* () {
return yield* fn(yield* operation);
})),
catch: (fn) =>
from(call(function* () {
try {
return yield* operation;
} catch (e) {
return yield* fn(e);
}
})),
finally: (fn) =>
from(call(function* () {
try {
return yield* operation;
} finally {
yield* fn();
}
})),
};
}
It would be used as:
import { lift } from "effection"
// create an operation function
let one = lift(() => 1);
let doubled = from(one())
.then(function*(value) {
return value * 2;
});
We can add this to the docs, create an @effectionx package, and then maybe even see about adding it to core.