neverthrow icon indicating copy to clipboard operation
neverthrow copied to clipboard

[PROPOSAL] Generator-based DSL for linear ResultAsync composition

Open hosuaby opened this issue 8 months ago • 0 comments

Hello,

I’ve recently adopted neverthrow in my project and I already love it. I'm using ResultAsync extensively to make the return types of async methods explicit and robust.

However, I've found it increasingly difficult to compose complex async workflows that involve multiple branches, conditionals, and loops. Chaining with .andThen leads to deeply nested, hard-to-read code.

To solve this, I built a pseudo-linear DSL for ResultAsync composition using generator functions, with short-circuiting error propagation. It was inspired by Effect.gen from the effect library in the functional TypeScript ecosystem, and by do notation from Haskell.

Here’s an example of how it looks in practice:

public renderDocument(
  document: Document<DocumentContentInlined>,
): ResultAsync<DeliveredArtifact, RenderError | DeliveryError> {
  return asyncProgram(
    function* (
      this: RenderPipeline,
    ): AsyncProgram<DeliveredArtifact, RenderError | DeliveryError> {
      // renderDocument(document, contentSchema): ResultAsync<Artifact[], RenderError>
      const artifacts: Artifact[] = yield this.renderer.renderDocument(
        document,
        this.contentSchema,
      );

      const main = artifacts.filter((artifact) => artifact.isMain);
      if (!main.length) {
        return errAsync(new RenderError('Renderer must produce one main artifact.'));
      } else if (main.length > 1) {
        return errAsync(new RenderError('Renderer cannot produce multiple main artifacts.'));
      }

      let mainArtifact: DeliveredArtifact | undefined;

      for (const artifact of artifacts) {
        // deliverArtefact(artifact): ResultAsync<DeliveredArtifact, DeliveryError>
        const deliveredArtifact = yield this.deliveryLayer.deliverArtefact(artifact);
        if (artifact.isMain) {
          mainArtifact = deliveredArtifact;
        }
      }

      return mainArtifact!;
    }.bind(this),
    (defect) => errAsync(new DeliveryError('Defective renderDocument program', defect)),
  );
}

This approach:

  • Allows writing async flows in a linear, readable style
  • Propagates the first error automatically without nesting
  • Supports early returns via errAsync(...)
  • Plays nicely with TypeScript and neverthrow's type system

If this is of interest, I’d be happy to open a PR. For now, you can check out:

Implementation: https://github.com/sapphire-cms/sapphire-cms/blob/master/packages/core/src/common/async-program.ts

Example of usage: https://github.com/sapphire-cms/sapphire-cms/blob/master/packages/core/src/services/render-pipeline.ts

hosuaby avatar May 04 '25 23:05 hosuaby