result/README.md

126 lines
5.6 KiB
Markdown
Raw Normal View History

# @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.
1. 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.
```typescript
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