Thomas Gideon
34a930fa5d
- Separate out from old mono repository. - Adjust name including new namespace. - Adjust version to pre-1.0 |
||
---|---|---|
.yarn | ||
src | ||
.eslintignore | ||
.eslintrc.js | ||
.gitignore | ||
.prettierignore | ||
.prettierrc.json | ||
.yarnrc.yml | ||
jest.config.js | ||
package.json | ||
README.md | ||
tsconfig.json | ||
yarn.lock |
@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.
- The
@snowfrog
package does not play well with async code. - The
isOk
andisErr
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 useexpect
/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, useSync
for non-awaitable code since it is likely less frequently used