Promise and Observable have had a baby. This baby is called LazyPromise. It inherits certain features from both mom and dad: the general shape of the API from Promise and laziness/cancelability from Observable. Besides, it has a trait of its own, which is typed errors.
Vs Promise
A lazy promise is like a promise, with three differences:
It has typed errors
It's lazy and cancelable
It emits synchronously instead of on the microtask queue.
You create one much like you call the Promise constructor, except you can optionally return a teardown function, for example:
const lazyPromise = createLazyPromise<0, "oops">((resolve, reject) => {
const timeoutId = setTimeout(() => {
if (Math.random() > 0.5) {
resolve(0);
} else {
reject("oops");
}
}, 1000);
return () => {
clearTimeout(timeoutId);
};
});
Unlike a promise, a lazy promise doesn't do anything until you subscribe to it:
// `unsubscribe` is an idempotent `() => void` function.
const unsubscribe = lazyPromise.subscribe(handleValue, handleError);
Besides being lazy, LazyPromise is cancelable: if the subscriber count goes down to zero before the promise has had time to fire, the teardown function will be called and we'll be back to square one.
If a lazy promise does fire, then like a regular promise it will remember forever the value or error, and give it to whoever tries to subscribe in the future.
The errors are typed and .subscribe(...)
function requires that you provide a handleError
callback unless the type of errors is never
.
Typed errors mean that you don't reject lazy promises by throwing an error, but only by calling reject
. If you do throw, two things will happen. First, the error will be asynchronously re-thrown so it would be picked up by the browser console, Sentry, Next.js error popup etc. Second, a notification will be sent down a third "failure" channel that exists in addition to the value and error channels. It does not pass along the error, but just tells subscribers that there is no resolve or reject forthcoming:
// `fail` has signature `() => void`.
const lazyPromise = createLazyPromise((resolve, reject, fail) => {
// Throwing here is the same as calling `fail`.
// If you throw in setTimeout, LazyPromise will have no way of
// knowing about it, so `fail` has to be called explicitly.
setTimeout(() => {
try {
...
} catch (error) {
fail();
throw error;
}
});
});
// `handleFailure` has signature `() => void`.
lazyPromise.subscribe(handleValue, handleError, handleFailure);
Instead of dot-chaining LazyPromise uses pipes: pipe(x, foo, bar)
is the same as bar(foo(x))
. Also, there are small naming differences. That aside, LazyPromise API mirrors that of Promise:
Promise api | LazyPromise equivalent |
---|---|
promise.then(foo) |
pipe(lazyPromise, map(foo)) |
promise.catch(foo) |
pipe(lazyPromise, catchRejection(foo)) |
promise.finally(foo) |
pipe(lazyPromise, finalize(foo)) |
Promise.resolve(value) |
resolved(value) |
Promise.reject(error) |
rejected(error) |
new Promise<never>(() => {}) |
never |
Promise.all(...) |
all(...) |
Promise.any(...) |
any(...) |
Promise.race(...) |
race(...) |
x instanceof Promise |
isLazyPromise(x) |
Promise<Value> |
LazyPromise<Value, Error> |
Awaited<T> |
LazyPromiseValue<T> , LazyPromiseError<T>
|
In fact, if you know Promise and have read this article up to here, you know everything you need to know about the LazyPromise API other than a few random items:
There are utility functions
eager
andlazy
that convert to and from a regular promise.eager
takes a LazyPromise and returns a Promise,lazy
takes a functionasync (abortSignal) => ...
and returns a LazyPromise.There is
catchFailure
function analogous tocatchRejection
.-
An error will be thrown if you try to
- settle (resolve, reject, or fail) a lazy promise that is already settled or has no subscribers, with an exception that you can call
fail
in the teardown function - subscribe to a lazy promise inside its teardown function.
- settle (resolve, reject, or fail) a lazy promise that is already settled or has no subscribers, with an exception that you can call
Vs Observable
Here's how LazyPromise differs from its other parent Observable:
Again, it has typed errors
It can only generate a value once.
In the previous section I told you what LazyPromise is, and here I'm going to show you where its design comes from.
If you want an async primitive with cancelability, you can't get any simpler than
const thing = (resolve, reject) => {
// Now or later call resolve/reject
return () => {
// Clean up
};
};
Once you have the resolve
handle, why limit oneself to only calling it once? — and so you get the Observable (aside from the complete
handle that does not matter for this discussion).
There's only one step left to get from here to LazyPromise, and this is to see why it's a good idea to only call resolve
once after all.
Consider the Diamond problem:
You've got an observable A
Observables B and C synchronously derive values from A
Observable D synchronously derives values from B and C.
Whenever the value of A changes, D redundantly recomputes its value twice: when reacting to the change in B and when reacting to the change in C.
There are only two ways to solve this. One is to track dependencies. Once you know the dependency graph, you can do things efficiently: in this case, compute B and C before computing D. This is Solid-style Signals. Your only other option is to give up ability to generate multiple values. This is LazyPromise. Since it doesn't have to track dependencies, it can keep the shape of Observable, but it focuses on async and nothing but async, and leaves synchronous state changes to other primitives like Signals (go Signals!) or React state.
I have just one thing to add here. I was talking about the Diamond problem because it is the easier one to explain, but there is another (and I think bigger) problem which similarly points to the need for one-value restriction. That problem is undesired behavior in the case of synchronous re-entry. We're talking about Observable invoking some client callback and then the callback sending the execution point back inside the observable. I'm not going to go into detail but will just give an RxJS example I once got bitten by:
import { BehaviorSubject, tap } from 'rxjs';
const subject = new BehaviorSubject(0);
subject
.pipe(tap((value) => value === 0 && subject.next(1)))
// Logs first 1, then 0. If instead of logging, we were
// performing some update, we'd get stuck with 0.
.subscribe(console.log);
Even with one-value restriction, LazyPromise still has to think about possible synchronous re-entry scenarios, but it's able to handle all of them perfectly well (and without resorting to microtasks).
Downsides
When deciding whether to use LazyPromise, there are downsides that you have to balance its features against.
No async/await
By far the most important one.
Long pipes
The pipes, not just in this context but in general, are a bit perfidious. They make it easy to avoid naming things, and this can trick you into not naming things that should be named to make the code readable. When we write a(b(...(x)))
, the many levels of nesting raise suspicion, but
pipe(
x,
a,
b,
...
)
looks innocuous even with a lot of elements in the pipe.
No microtask-based guarantees
In the following example, Promise guarantees that foo
will run after bar
, whereas LazyPromise doesn't:
promise.then(foo);
bar();
The first reason LazyPromise doesn't fire on the microtask queue is to be simple. You can add in the microtasks (code snippet below), but if they were baked in, you wouldn't be able to take them out.
const transform = <Value, Error>(source: LazyPromise<Value, Error>) =>
createLazyPromise<Value, Error>((resolve, reject, fail) => {
let disposed = false;
const dispose = source.subscribe(
(value) => queueMicrotask(() => !disposed && resolve(value)),
(error) => queueMicrotask(() => !disposed && reject(error)),
() => queueMicrotask(() => !disposed && fail()),
);
return () => {
dispose();
disposed = true;
};
});
The second reason is that microtasks are a bit of a deal with the Devil. Yes, they do give you the guarantee I mentioned, but if you have two async functions and want to know which one will finish first, the answer will depend basically on how many await
statements there are inside of each function. That's a race condition.
Third, not so much a reason, but a nice side-effect, is that you don't have the kind of performance issues that the React team had to hack around when building the use
hook.
Conclusion
LazyPromise is a primitive primitive that takes away async-await but gives you typed errors, cancelability, and familiar API. Use it if you dare! The repo is here, I'm here.
Top comments (2)
That's an intriguing idea.
To see it in action, I've made a little Stackblitz demo showing how you can use LazyPromise in a promise-friendly UI library like rimmel.js
Have you considered exposing a cancel function à la Promise.withResolvers, as it could make it easier to integrate with certain UI components?
Awesome! About the the cancel function, you could create a wrapper function that you would use instead of
createLazyPromise
and which would callcreateLazyPromise
internally and return the type signature you described. That's a fun part about a primitive, that you can build things on top of it that work well for a specific framework.