DEV Community

Cover image for LazyPromise: typed errors and cancelability for lazy people who don't want to learn a new API
Ivan Novikov
Ivan Novikov

Posted on • Edited on

1

LazyPromise: typed errors and cancelability for lazy people who don't want to learn a new API

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

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

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

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 and lazy that convert to and from a regular promise. eager takes a LazyPromise and returns a Promise, lazy takes a function async (abortSignal) => ... and returns a LazyPromise.

  • There is catchFailure function analogous to catchRejection.

  • 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.

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

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

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

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

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

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.

SurveyJS custom survey software

Simplify data collection in your JS app with a fully integrated form management platform. Includes support for custom question types, skip logic, integrated CCS editor, PDF export, real-time analytics & more. Integrates with any backend system, giving you full control over your data and no user limits.

Learn more

Top comments (2)

Collapse
 
dariomannu profile image
Dario Mannu

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?

const [promise, cancel] = createLazyPromise(...)
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ivan7237d profile image
Ivan Novikov • Edited

Awesome! About the the cancel function, you could create a wrapper function that you would use instead of createLazyPromise and which would call createLazyPromise 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.

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs