Navigation

For library authors

Class-based API⁠#

To get the best performance, for instance when working on a library, you can avoid the overhead of creating and garbage-collecting functions by using objects in their place. Instead of passing a callback to the LazyPromise constructor, you can pass an object with a .produce method (a Producer), and instead of returning a teardown function, you can return an object with a .dispose method (a Job; [Symbol.dispose], when present, overrides .dispose).

AsyncContext and AsyncLocalStorage⁠#

If LazyPromise naively ignored Node’s AsyncLocalStorage (the same will apply to the upcoming cross-platform version AsyncContext), then in

all([lazyPromiseA, lazyPromiseB]).subscribe({ resolve: foo });

foo could run in a different async context depending on which lazy promise resolves first. Instead, LazyPromise runs the constructor callback, the consumer’s resolve/reject handlers, and the teardown logic in the context where subscribe was called. As a consequence, async context propagates upstream but not downstream, and generator functions behave the same way as async-await:

const als = new AsyncLocalStorage<string>();

const lazyPromise = fromGen(function* () {
  console.log(als.getStore()); // "a"
  yield* new LazyPromise<void>((sink) => {
    als.run("b", () => {
      sink.resolve();
    });
  });
  console.log(als.getStore()); // Still "a"
});

als.run("a", () => {
  lazyPromise.subscribe();
});

One way to think of async context is as an implicit counterpart of dependency injection.

Observability⁠#

Under the hood, the log method of a LazyPromise calls another method called trace. That method lets you observe a lazy promise without otherwise changing the behavior of the program, and you can use it to plug in tooling such as another logger, performance marks, or OpenTelemetry spans. trace takes a Tracer: an object with a subscribe(dep) method which is called each time the LazyPromise is subscribed. subscribe can return a Span: an object with optional methods resolve(value), reject(error), flatten(lazyPromise) (called when you sink.resolve with a LazyPromise), and unsubscribe(). trace returns a Tracing object whose dispose/[ Symbol.dispose] method detaches the tracer.

const tracing = lazyPromise.trace({
  subscribe() {
    const start = performance.now();
    return {
      resolve() {
        performance.measure("lazyPromise", { start });
      },
    };
  },
});

// Later: stop tracing new subscriptions. Existing spans are unaffected.
tracing.dispose();

When flattening, the subscribe notification for the inner promise is given the same subscription argument as the one for the outer promise.

A Span can also have a method run(work, depth) which wraps synchronous work done on behalf of the subscription (running the producer, the consumer or the teardown logic). depth lets you trace causality within async boundaries: it is what log turns into dots (actual nesting of run calls does not follow depth and should not be relied on). You can also trace causality across async boundaries by writing to async context (currently Node’s AsyncLocalStorage) in run and reading from it in the other methods of Span, as demonstrated in traceAsync.test.ts.

Interop (almost) without a dependency⁠#

Suppose your library wants to accept lazy promises from its users, but most of its users don’t use LazyPromise, so you’d rather not depend on @lazy-promise/core. For that there is @lazy-promise/interop: a tiny package with no dependencies that @lazy-promise/core itself depends on. It gives you:

  • isLazyPromiseLike(value), a type guard that recognizes lazy promises.

  • LazyPromiseLike<Value, Dep>, a part of the LazyPromise type with a subscribe(consumer, dep) method returning a SubscriptionLike (an object with a dispose() method). Every LazyPromise<Value, Dep> is assignable to LazyPromiseLike<Value, Dep>.

  • ErrorBox, NotAnErrorBox, UnboxError and Consumer that core re-exports.

Here is an example: a function that coerces a value to a non-LazyPromise or throws if that can’t be done synchronously. A lazy promise is only accepted if it has no dependencies (LazyPromiseLike<..., undefined>) and no unhandled boxed errors (LazyPromiseLike<NotAnErrorBox, ...>).

import {
  type LazyPromiseLike,
  type NotAnErrorBox,
  isLazyPromiseLike,
} from "@lazy-promise/interop";

const emptySymbol = Symbol("empty");

const unboxSync = <Value>(
  source:
    | Value
    // Requirements go in a second member: with `Value & NotAnErrorBox` as the
    // type argument, TS would infer `Value` from the bare member above instead.
    | (LazyPromiseLike<Value, any> & LazyPromiseLike<NotAnErrorBox, undefined>),
): Value => {
  if (!isLazyPromiseLike(source)) {
    return source;
  }
  let value: Value | typeof emptySymbol = emptySymbol;
  let error: unknown = emptySymbol;
  const subscription = (source as LazyPromiseLike<Value, undefined>).subscribe(
    {
      resolve: (resolvedValue) => {
        value = resolvedValue;
      },
      reject: (rejectionError) => {
        error = rejectionError;
      },
    },
    undefined,
  );
  if (error !== emptySymbol) {
    throw error;
  }
  if (value !== emptySymbol) {
    return value;
  }
  subscription.dispose();
  throw new Error("The lazy promise did not settle synchronously.");
};

// Type inferred as number
unboxSync(42);
// Type inferred as string
unboxSync(new LazyPromise<string>((sink) => sink.resolve("a")));
// Type inferred as string | null
unboxSync(condition ? new LazyPromise<string>(...) : null);
// Typechecking error: `ErrorBox<"oops">` is not assignable to `NotAnErrorBox`.
unboxSync(new LazyPromise<string | ErrorBox<"oops">>(() => {}));
// Typechecking error: `undefined` is not assignable to `MyDep`.
unboxSync(new LazyPromise<string, MyDep>(() => {}));

If your library needs to transform a lazy promise it was given into another one, and you can get away with not using the LazyPromise type in your declarations, you can do the following:

const newLazyPromise = new (originalLazyPromise.constructor as any)(...);

If you do need the LazyPromise type, this would require @lazy-promise/core as an optional peer dependency.