result/README.md
Thomas Gideon 34a930fa5d Initial commit
- Separate out from old mono repository.
- Adjust name including new namespace.
- Adjust version to pre-1.0
2024-06-24 17:01:32 -04:00

5.6 KiB

@cmdln/result

This module implements my error type for Typescript, Result<T, E>. After trying out @snowfrog/result, I implemented my own for a couple of reasons.

  1. The @snowfrog package does not play well with async code.
  2. The isOk and isErr methods could have done more in @snowfrog to help access values and errors safely with less boiler plate. In the @snowfrog code, the guard methods do not narrow in all cases. In particular, the implicit else of a short-circuit pattern doesn't work. This creates toil in that the call has to use expect/expectErr which reduces readability and with Typescript's flow control analysis shouldn't be necessary.

In addition to Result, this package includes some useful facilities for working with that type and converting code that uses thrown exceptions into Result returning code.

Converting Thrown Exceptions

The high order functions trySync and tryAsync each accept two function as arguments. The first is a no argument closure for running some code that may thrown an exception. The second is a closure accepting an unknown error that can then be narrowed, wrapped, and returned to change any thrown exception into an Err.

Chains of Promises and Result

Result shines when using its various combinators, like andThenAsync and yield. These high order functions accepts closures that allow for changing the success and error types to new values of different types and even changing an Ok to an Err and vice versa.

Promise has a similar design and in theory the types work well together. There are some problems due to the special handling Promise gets when used with the await keyword. The compiler and runtime literally expect a Promise and won't accept a shape that is merely assignable. This forces some diagonal code, similar to nested callbacks, to call Result combinators from closures passed to Promise's own combinators, especially then.

The resultify function sugars over the two types to allow use with await without the added boiler plate needed to access the Result combinators without always fully resolving the promise.

Example

This example is excerpted from a past project and uses some structured http errors from another package I wrote.

import { BaseContext, BaseError, Result } from "@cmdln/error";
import { passOrYield, resultify, trySync } from "@cmdln/result";
import { NextFunction, Request, Response } from "express";
import { FanOutName } from "fanout";
import { IncomingHttpHeaders } from "http";
import { getLogger } from "index";
import { sendMessage } from "producer";
import { Webhook } from "svix";
import { ID_HEADER, SIGNATURE_HEADER, TIMESTAMP_HEADER } from "./constants";

export async function handleClerkWebhook(
  request: Request,
  response: Response,
  next: NextFunction
) {
  const logger = getLogger(__filename);
  logger.info("Received Clerk webhook request.");
  const { body: payload, headers } = request;
  // without resultify here, the result's combinators would have to be nested
  // in a .then call
  await resultify(tryVerify(payload, headers))
    .andThenAsync(async (verified) => {
      logger.debug({ verified }, "Verified call from Clerk");
      return await sendMessage(FanOutName.Clerk, verified);
    })
    .yield(() => {
      request.log.debug("Webhook successfully received message");
      response.status(200).json({ message: "Success" });
      next();
    })
    .yieldErr((cause) => {
      request.log.error({ cause }, "Clerk webhook failed");
      response.status(400).json({});
      next(cause);
    });
}

function tryVerify(
  payload: string,
  headers: IncomingHttpHeaders
): Result<unknown> {
  const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET);
  // there is also tryAsync which works the same way for async code
  // remember that if your logic in the first argument returns a value, you
  // will need to return the call as well; a common mistake is leaving out a
  // return or worse an await with tryAsync
  return trySync<unknown, BaseError>(
    () => {
      // a common practice in our code is to use assertion functions in
      // trySync/tryAsync since often we are using a client library and need to
      // narrow the result; this use case is a little different but a valid
      // example of the pattern
      assertClerkHeaders(headers);
      return wh.verify(payload, headers);
    },
    // if we know of any thrown errors that are compatible with our Result's
    // generic bindings, this helper function saves more boiler plate
    passOrYield(VerifyError, (cause) => {
      return VerifyError.err(VerifyErrorKind.InvalidSignature, { cause });
    })
  );
}

enum VerifyErrorKind {
  InvalidSignature = "Failed to verify signature on call from Clerk",
  MissingHeaders = "Missing required headers on call from Clerk",
}

type VerifyContext = BaseContext & {
  headers?: unknown;
};

class VerifyError extends BaseError {
  static err(kind: VerifyErrorKind, context: VerifyContext) {
    return new VerifyError(500, kind, context);
  }

  get name() {
    return VerifyError.name;
  }
}

function assertClerkHeaders(
  headers: IncomingHttpHeaders
): asserts headers is Record<string, string> {
  if (
    !headers[ID_HEADER] ||
    !headers[TIMESTAMP_HEADER] ||
    !headers[SIGNATURE_HEADER]
  ) {
    throw VerifyError.err(VerifyErrorKind.MissingHeaders, { headers });
  }
}

To Do

  • Figure out if async and sync combinators can be cleanly merged similar to Jest's signature for its builder HOF's
  • Remove Async suffix for methods names, use Sync for non-awaitable code since it is likely less frequently used