Navigation

Type-safe errors

ErrorBox#

The way that LazyPromise supports type-safe errors reflects the JavaScript reality that you cannot typecheck errors that you throw and have to represent typed errors with return values. Instead of having an extra channel in addition to resolve and reject, we pass typed errors through the resolve channel, wrapping them in ErrorBox class to differentiate them from other values. new ErrorBox(error) simply stores error in its .error property.

catchBoxed#

There is an operator catchBoxed which is a boxed error counterpart of catch:

new LazyPromise<number | ErrorBox<"oops">>((sink) => {
  ...
  sink.resolve(new ErrorBox("oops"));
  ...
}).catchBoxed(
  // Type inferred as "oops"
  (error) => ...,
);

How other APIs treat error boxes#

ErrorBox instances are treated differently from other values by some of the LazyPromise APIs:

  • If you call .subscribe or .toEager on a LazyPromise that can resolve to boxed errors, you’ll get a typechecking error. This makes sure that if for example you add a new error to a server endpoint, you’ll catch all the places on the client where that error isn’t handled. The .subscribe method has an optional generic type parameter WhitelistedError that you can use to silence the check for some or all errors.

  • map, all, and race operators pass boxed errors through the same way they pass through rejections, e.g.

    declare const promiseA: LazyPromise<number | ErrorBox<"oops">>;
    
    // Type inferred as LazyPromise<string | ErrorBox<"oops">>
    const promiseB = promiseA.map(
      (
        // Type inferred as number
        value,
      ) => String(value),
    );
  • We talked about how when lazyPromise rejects with error, yield* lazyPromise acts exactly like throw error. If lazyPromise resolves with an ErrorBox instance boxedError, yield* lazyPromise acts exactly like return boxedError. In both cases the execution of the generator function is interrupted, the only difference is that you can’t catch a boxed error: you have to use the catchBoxed operator instead. If the execution continues, we know that lazyPromise has resolved with something other than a boxed error:

    declare const promiseA: LazyPromise<number | ErrorBox<"oops">>;
    
    // Type inferred as LazyPromise<string | ErrorBox<"oops">>
    const promiseB = fromGen(function* () {
      // Type inferred as number
      const value = yield* promiseA;
      return String(value);
    });

Usage with async-await#

It’s sometimes convenient to use LazyPromise in some parts of your codebase (e.g. on the client), and async-await in others (e.g. on the server, if you don’t need cancellation and often have to call Promise-based APIs). In that case you can still have the async-await code produce typed errors by returning error boxes, and converting values of the shape Promise<... | ErrorBox<...>> into lazy promises using fromEager.

any#

Typed errors are optional in the sense that you can pretend that the concept does not exist as long as you don’t use the ErrorBox class. There’s one exception to this which is the any operator, but this is only because that operator isn’t very ergonomic without typed errors anyway. When one of the promises passed to the native Promise.any rejects because of a bug, the bug passes undetected if some other input promise resolves. The LazyPromise version of any works like Promise.any with respect to boxed errors, but rejects if just one input rejects:

declare const promiseA: LazyPromise<string | ErrorBox<"a">>;
declare const promiseB: LazyPromise<number | ErrorBox<"b">>;

// Type inferred as LazyPromise<string | number | ErrorBox<["a", "b"]>>
const lazyPromise = any([promiseA, promiseB]);

UnboxError#

This helper type extracts the error from the type a LazyPromise resolves with, so UnboxError<number | ErrorBox<"oops">> will give you "oops".