Initial commit

- Separate out from old mono repository.
- Adjust name including new namespace.
- Adjust version to pre-1.0
This commit is contained in:
Thomas Gideon 2024-06-24 16:51:58 -04:00
commit 34a930fa5d
27 changed files with 10455 additions and 0 deletions

2
.eslintignore Normal file
View file

@ -0,0 +1,2 @@
dist
node_modules

10
.eslintrc.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
],
};

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# compiled outputs
dist
*tgz
# generated type definitions
*d.ts
# generated source maps
*map
# dependencies
node_modules
# incremental compilation status (not used in CI/CD)
tsconfig.tsbuildinfo
# yarn internals
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# test coverage report
coverage
.envrc
.env

8
.prettierignore Normal file
View file

@ -0,0 +1,8 @@
/node_modules/
/dist/
*md
*bash
*py
*js
*txt

1
.prettierrc.json Normal file
View file

@ -0,0 +1 @@
{}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

768
.yarn/releases/yarn-3.1.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

22
.yarnrc.yml Normal file
View file

@ -0,0 +1,22 @@
nodeLinker: node-modules
npmAlwaysAuth: false
npmRegistryServer: "https://registry.npmjs.com"
npmScopes:
caredge:
npmAlwaysAuth: true
npmAuthToken: "${NPM_AUTH_TOKEN:-}"
npmPublishRegistry: "https://npm.pkg.github.com"
npmRegistryServer: "https://npm.pkg.github.com"
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
spec: "@yarnpkg/plugin-typescript"
- path: .yarn/plugins/@yarnpkg/plugin-outdated.cjs
spec: "https://mskelton.dev/yarn-outdated/v3"
yarnPath: .yarn/releases/yarn-3.1.1.cjs

125
README.md Normal file
View file

@ -0,0 +1,125 @@
# @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

37
jest.config.js Normal file
View file

@ -0,0 +1,37 @@
const { compilerOptions } = require("./tsconfig.json");
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testPathIgnorePatterns: ["dist"],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{ tsconfig: compilerOptions },
],
},
collectCoverage: true,
collectCoverageFrom: ["**/src/**/*.ts"],
coveragePathIgnorePatterns: ["node_modules/", "__test__/"],
coverageReporters: ["html", "text-summary"],
coverageThreshold: {
global: {
branches: 100,
},
// shared enums
"packages/db-enums/src/": {
branches: 0
},
"packages/orm/src/repository": {
branches: 0
},
// test utilities
"packages/orm-test/src/": {
branches: 0
},
"packages/result-test/src/": {
branches: 0
}
}
};

45
package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "@cmdln/result",
"author": "cmdln",
"version": "0.1.2",
"description": "Try type for functional programming plus useful utilities.",
"repository": "https://git.cmdln.net/cmdln/result",
"license": "MIT",
"packageManager": "yarn@3.1.1",
"main": "dist/index.js",
"types": "types/index.d.ts",
"exports": {
".": "./dist/index.js"
},
"scripts": {
"clean": "yarn cache clean && rm -rf dist",
"lint": "yarn run eslint . --ext .ts",
"lint:fix": "yarn run eslint --fix . --ext .ts",
"format:check": "yarn run prettier --check src/**",
"format": "yarn run prettier --write src/**",
"test": "yarn jest",
"pre:push": "yarn lint && yarn format:check && yarn compile && yarn test && yarn outdated -c -s minor",
"compile": "yarn tsc -b"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/lodash.isnil": "^4.0.9",
"@types/node": "^20.11.20",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"jest": "^29.7.0",
"lerna": "^8.1.2",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"dependencies": {
"lodash.isnil": "^4.0.0"
},
"files": [
"dist/*"
]
}

View file

@ -0,0 +1,95 @@
import { Err, Ok } from "..";
import { isResult, __internals } from "../guards";
describe("guards.ts", () => {
/* eslint-disable @typescript-eslint/no-empty-function */
const combinators = {
and: () => {},
andThen: () => {},
andThenAsync: () => {},
or: () => {},
orElse: () => {},
orElseAsync: () => {},
yield: () => {},
yieldAsync: () => {},
yieldErr: () => {},
yieldOr: () => {},
yieldOrElse: () => {},
};
/* eslint-enable */
const mockSuccess = {
kind: "ok",
_value: true,
isOk: () => true,
...combinators,
};
const mockError = {
kind: "err",
_error: true,
isErr: () => true,
...combinators,
};
describe("isResult", () => {
it.each([mockError, mockSuccess, Err(true), Ok(true)])(
"successfully narrows %s",
(r) => {
expect(isResult(r)).toBeTruthy();
},
);
it.each([
{ kind: "ok", _value: true },
prune(mockSuccess, "isOk"),
prune(mockSuccess, "andThen", "orElse"),
{ kind: "err", _error: true },
prune(mockError, "isErr"),
prune(mockError, "andThen", "orElse"),
])("rejects %s correctly", (r) => {
expect(isResult(r)).toBeFalsy();
});
});
describe("isSuccess", () => {
it.each([Ok(true), mockSuccess])("narrows %s successfully", (r) => {
expect(__internals.isSuccess(r)).toBeTruthy();
});
it.each([
Err(true),
mockError,
{ kind: "ok", _value: true },
prune(mockSuccess, "isOk"),
prune(mockSuccess, "andThen", "orElse"),
])("rejects %s correctly", (r) => {
expect(__internals.isSuccess(r)).toBeFalsy();
});
});
describe("isError", () => {
it.each([Err(true), mockError])("narrows %s successfully", (r) => {
expect(__internals.isError(r)).toBeTruthy();
});
it.each([
Ok(true),
mockSuccess,
{ kind: "err", _error: true },
prune(mockError, "isErr"),
prune(mockError, "andThen", "orElse"),
])("rejects %s correctly", (r) => {
expect(__internals.isError(r)).toBeFalsy();
});
});
});
function prune(u: object, ...fields: string[]): object {
const copy = { ...u };
for (const field of fields) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (copy as any)[field];
}
return copy;
}

178
src/__test__/index.test.ts Normal file
View file

@ -0,0 +1,178 @@
import { inspect } from "util";
import { AsyncResult, Err, Ok, Result, resultify } from "../";
import { tryAssertErr, tryAssertOk } from ".";
function ntoa(n: number): string {
return `${n}`;
}
function tryNtoA(n: number): Result<string, []> {
return Ok(ntoa(n));
}
async function tryNtoAAsync(n: number): AsyncResult<string, []> {
return tryNtoA(n);
}
function tryAtoN(a: string): Result<number, []> {
try {
const n = Number(a);
return Number.isNaN(n) ? Err([]) : Ok(n);
} catch (e) {
return Err([]);
}
}
async function tryAtoNAsync(a: string): AsyncResult<number, []> {
return tryAtoN(a);
}
describe("@/api/common/result", () => {
describe("Result", () => {
it("isOk narrows in short circuit", () => {
const r = tryNtoA(1).andThen(tryAtoN);
if (r.isOk()) {
const t: number = r.value;
expect(t).toEqual(1);
return;
}
// never executes but does compile
expect(r.error).toEqual([]);
});
it("isErr narrows field in short circuit", () => {
const r = tryAtoN("x");
if (r.isErr()) {
const e: [] = r.error;
expect(e).toEqual([]);
return;
}
// will never execute, demonstrates that tsc still narrows
expect(r.value).toEqual(1);
});
it("flat maps a success", () => {
const r = tryNtoA(1);
expect(r.isOk()).toBeTruthy();
expect(r.isErr()).toBeFalsy();
tryAssertOk(r, (t) => {
expect(t).toEqual("1");
});
const r2 = r.andThen(tryAtoN);
expect(r2.isOk()).toBeTruthy();
expect(r2.isErr()).toBeFalsy();
tryAssertOk(r2, (t) => {
expect(t).toEqual(1);
});
});
it("async flat maps a success", async () => {
const r = await tryNtoAAsync(1);
expect(r.isOk()).toBeTruthy();
expect(r.isErr()).toBeFalsy();
tryAssertOk(r, (t) => {
expect(t).toEqual("1");
});
const r2 = await resultify(tryNtoAAsync(1)).andThenAsync(tryAtoNAsync);
expect(r2.isOk()).toBeTruthy();
expect(r2.isErr()).toBeFalsy();
tryAssertOk(r2, (t) => {
expect(t).toEqual(1);
});
});
it("flat maps an error", () => {
const r = tryAtoN("x");
expect(r.isErr()).toBeTruthy();
expect(r.isOk()).toBeFalsy();
const e = r.andThen(tryNtoA);
expect(e.isErr()).toBeTruthy();
expect(e.isOk()).toBeFalsy();
});
it("async flat maps an error", async () => {
const r = tryAtoN("x");
expect(r.isErr()).toBeTruthy();
expect(r.isOk()).toBeFalsy();
const e = await r.andThenAsync(tryNtoAAsync);
expect(e.isErr()).toBeTruthy();
expect(e.isOk()).toBeFalsy();
});
it("maps a success", () => {
const r = tryAtoN("1").yield((n) => n + 1);
tryAssertOk(r, (t) => {
expect(t).toEqual(2);
});
});
it("async maps a success", async () => {
const r = await tryAtoN("1").yieldAsync(async (n) => n + 1);
tryAssertOk(r, (t) => {
expect(t).toEqual(2);
});
});
it("maps an error", () => {
const e = tryAtoN("x").yield((n) => n + 1);
expect(e.isErr()).toBeTruthy();
expect(e.isOk()).toBeFalsy();
});
it("maps an error type on success", () => {
const r = tryAtoN("1").yieldErr((e) => {
inspect(e);
return "failed";
});
expect(r.isOk()).toBeTruthy();
expect(r.isErr()).toBeFalsy();
});
it("maps an error type on error", () => {
const e = tryAtoN("x").yieldErr((e) => {
inspect(e);
return "failed";
});
expect(e.isOk()).toBeFalsy();
expect(e.isErr()).toBeTruthy();
tryAssertErr(e, (e) => {
expect(e).toEqual("failed");
});
});
it("and handles success", () => {
const r: Result<string, []> = tryAtoN("1").and(Ok("success"));
expect(r.isOk()).toBeTruthy();
expect(r.isErr()).toBeFalsy();
tryAssertOk(r, (t) => {
expect(t).toEqual("success");
});
});
it("and handles error", () => {
const r: Result<string, []> = tryAtoN("x").and(Ok("success"));
expect(r.isOk()).toBeFalsy();
expect(r.isErr()).toBeTruthy();
});
it("or handles success", () => {
const r: Result<number, string> = tryAtoN("1").or(Ok(0));
expect(r.isOk()).toBeTruthy();
expect(r.isErr()).toBeFalsy();
tryAssertOk(r, (t) => {
expect(t).toEqual(1);
});
});
it("or handles error", () => {
const r: Result<number, string> = tryAtoN("x").or(Ok(0));
expect(r.isOk()).toBeTruthy();
expect(r.isErr()).toBeFalsy();
tryAssertOk(r, (t) => {
expect(t).toEqual(0);
});
});
});
});

18
src/__test__/index.ts Normal file
View file

@ -0,0 +1,18 @@
import { inspect } from "util";
import { Result } from "..";
export function tryAssertOk<T, E>(r: Result<T, E>, a: (t: T) => void) {
if (r.isOk()) {
a(r.value);
} else {
throw `Result should have been Ok but was ${inspect(r, undefined, 15)}`;
}
}
export function tryAssertErr<T, E>(r: Result<T, E>, a: (e: E) => void) {
if (r.isErr()) {
a(r.error);
} else {
throw `Result should have been Err but was ${inspect(r, undefined, 15)}`;
}
}

View file

@ -0,0 +1,38 @@
import { Ok, Result } from "..";
import { resultify } from "../resultify";
describe("resultify", () => {
it("skips if a promise is already resultified", () => {
const promise = Promise.resolve(Ok(0));
const resultified = resultify(promise);
// some state to close on to help testing
let called = 0;
// replace a simple combinator with a compatible one that tracks state
resultified.and = <U>(r: Result<U, unknown>) => {
called += 1;
return resultify(Promise.resolve(r));
};
resultified.and(Ok(1));
expect(called).toEqual(1);
// calling again should *not* replace the custom combinator
resultify(resultified);
resultified.and(Ok(2));
// should still increment if resultify skipped an already resultified
// promise
expect(called).toEqual(2);
});
it("throws if called with undefined", () => {
expect(() =>
resultify(undefined as unknown as Promise<Result<unknown, unknown>>),
).toThrow();
});
it("accepts a result and wraps with a promise", async () => {
expect(
await resultify(Ok(3)).andThenAsync(
async (i): Promise<Result<number, never>> => Ok(i * 2),
),
).toEqual(Ok(6));
});
});

30
src/__test__/unit.test.ts Normal file
View file

@ -0,0 +1,30 @@
import { passOrYield } from "../unit";
class TestError {
constructor(readonly message: string) {}
}
describe("passOrYield", () => {
const f = () => new TestError("Assert!");
it("passes constructed error correctly", () => {
const e = new TestError("Failed!");
expect(passOrYield(TestError, f)(e)).toEqual(e);
});
it("handles thrown object", () => {
try {
throw new TestError("Thrown!");
} catch (e) {
expect(passOrYield(TestError, f)(e)).toEqual(e);
}
});
it("handles a synthetic error", () => {
// 3rd party code can mangle our errors but usually produce a descendant
// of the builtin, Error, and preserve the name from our classes
// constructor function
const synthetic = new Error("Synthetic!");
synthetic.name = TestError.name;
expect(passOrYield(TestError, f)(synthetic)).toEqual(synthetic);
});
});

105
src/carry.ts Normal file
View file

@ -0,0 +1,105 @@
import { AsyncResult, Result } from ".";
import { resultify } from "./resultify";
type Arg = unknown;
/**
* This function is designed to make for comprehensions a little easier to compose without diagonalizing.
*
* A for comprehension is a pattern of chaining `Result` with a series of `andThen`/`andThenAsync` calls, passing
* along incremental plus accumulated results to each next step, then making a final yield call to get the total
* output from the chain if all were successful.
*
* NOTE: Because of limitations of Typescript, incremental results, that is the new value produce by an `andThen`/
* `andThenAsync`, will be *pre*-pended to the front of the accumulated results. Also, tsc can quickly fail to infer
* the changes to the accumulated type, a tuple to which each incremental type will be added. Adding a type annotation
* between the `andThen`/`andThenAsync` and the open parenthesis for the arguments should be sufficient to clear up
* tsc's confusion.
*
* Example, from MQ's side effect handling when a subscription changes:
*
* return await resultify(tryNarrowSubscription(event.data)).andThenAsync(
* async ({ object: subscription }) => {
* return await resultify(tryGetSubscription(subscription))
* .andThen(tryNarrowSubscriptionCustomerId)
* // type hint here because `carry` is used to add the determined plan
* // to the tuple already containing the retrieved and narrowed subscription
* .andThen<[PlanType, SubscriptionWithCustomerId]>(
* (subscription) => {
* const { price } = subscription.items.data[0];
* return tryDeterminePlan(price).yield(carry(subscription));
* },
* )
* // and another link in the chain, adding the user from the database to
* // the tuple that has already accumulated the subscription and plan type
* .andThenAsync<[User, PlanType, SubscriptionWithCustomerId]>(
* async ([planType, subscription]) => {
* const { customer: stripeCustomerId } = subscription;
* return resultify(
* getRepository(User).tryFindStripeCustomer(stripeCustomerId),
* ).yield(carry(planType, subscription));
* },
* )
* // this is still part of the for comprehension, it happens to consume some of the state
* // and uses a simple yield call without carry to set a constant output, that the event was
* // handled
* .andThenAsync(async ([user, planType, subscription]) => {
* return await resultify(
* createMauticContact(
* QueueName.MauticStripe,
* user.email,
* planType,
* user?.firstName,
* subscription.status,
* ),
* ).yield(() => EventOutcome.Handled);
* })
* .orElse(skipUnknownPlan)
* // here the final yield is simply doing some logging but in API-land, a more typical pattern
* // would be to add the accumulated outputs from the rest of the comprehension into some singular value
* // other than the tuple used simply to carry those outputs to this last step
* .yield(
* logOutcome(
* logger,
* outcomeMessage("mautic", "Stripe", event.type),
* event,
* ),
* );
* },
* );
*/
export function carry<T extends Array<Arg>, U>(...ts: T): (u: U) => [U, ...T] {
return (u: U) => {
return [u, ...ts];
};
}
/**
* An alternative to `carry` that can be used to wrap a closure before passing it to `andThen` so that there is no
* need to manually add a `.yield(carry(...))` in the closure's logic.
*
* NOTE: This currently doesn't work well with longer for comprehensions since it makes the flow of types a little
* more opaque to tsc than simply using `carry`.
*/
export function yieldWithSync<T extends Array<Arg>, U, E>(
f: (ts: T) => Result<U, E>,
): (ts: T) => Result<[U, ...T], E> {
return (ts: T) => {
return f(ts).yield((u) => [u, ...ts]);
};
}
/**
* An alternative to `carry` that can be used to wrap a closure before passing it to `andThen` so that there is no
* need to manually add a `.yield(carry(...))` in the closure's logic.
*
* NOTE: This currently doesn't work well with longer for comprehensions since it makes the flow of types a little
* more opaque to tsc than simply using `carry`.
*/
export function yieldWith<T extends Array<Arg>, U, E>(
f: (ts: T) => AsyncResult<U, E>,
): (ts: T) => AsyncResult<[U, ...T], E> {
return async (ts: T) => {
return await resultify(f(ts)).yield((u) => [u, ...ts]);
};
}

22
src/combinators.ts Normal file
View file

@ -0,0 +1,22 @@
import { Ok, Result } from ".";
export function and<T, U, E>(r: Result<T, E>, u: Result<U, E>): Result<U, E> {
return r.andThen(() => u);
}
export function or<T, E, F>(r: Result<T, E>, u: Result<T, F>): Result<T, F> {
return r.orElse(() => u);
}
export async function mapAsync<T, U, E>(
r: Result<T, E>,
f: (t: T) => Promise<U>,
): Promise<Result<U, E>> {
return await r.andThenAsync(async (t) => Ok(await f(t)));
}
export function mapSync<T, U, E>(
r: Result<T, E>,
f: (t: T) => U,
): Result<U, E> {
return r.andThen((t) => Ok(f(t)));
}

67
src/guards.ts Normal file
View file

@ -0,0 +1,67 @@
import { Result } from ".";
/**
* A useful guard for the our handler framework where a route function can
* return `void` to indicate it is handling the response or a `Result` so the
* framework can automatically handle success and failure.
*/
export function isResult<T, E>(u: unknown): u is Result<T, E> {
return (
u instanceof Result.Ok ||
u instanceof Result.Err ||
isSuccess(u) ||
isError(u)
);
}
function isSuccess<T>(u: unknown): u is Result.Ok<T> {
return (
isObject(u) &&
"kind" in u &&
u.kind === "ok" &&
"_value" in u &&
hasMethod(u, "isOk") &&
hasCombinators(u)
);
}
function isError<T>(u: unknown): u is Result.Ok<T> {
return (
isObject(u) &&
"kind" in u &&
u.kind === "err" &&
"_error" in u &&
hasMethod(u, "isErr") &&
hasCombinators(u)
);
}
function isObject(u: unknown): u is object {
return u !== undefined && u !== null && typeof u === "object";
}
function hasMethod(u: object, method: string): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return method in u && typeof (u as any)[method] === "function";
}
function hasCombinators(u: object): boolean {
return [
"and",
"andThen",
"andThenAsync",
"or",
"orElse",
"orElseAsync",
"yield",
"yieldAsync",
"yieldErr",
"yieldOr",
"yieldOrElse",
].every((combinator) => hasMethod(u, combinator));
}
export const __internals = {
isError,
isSuccess,
};

312
src/index.ts Normal file
View file

@ -0,0 +1,312 @@
import * as combinators from "./combinators";
export { carry, yieldWith, yieldWithSync } from "./carry";
export { isResult } from "./guards";
export { resultify } from "./resultify";
export { passOrYield, tryAsync, trySync } from "./unit";
export function collectErrs<T, E>(rs: Result<T, E>[]): E[] {
return rs.filter((r) => r.isErr()).map((e) => e.error);
}
/**
* Interface for the success variant of a result, includes the tag, field, and
* guards to narrow and access the contained value.
*/
export interface Ok<T> {
kind: "ok";
value: T;
isOk(this: Result<T, unknown>): this is Ok<T>;
isErr(this: Result<T, unknown>): this is Err<never>;
}
/**
* Interface for the failure variant of a result, includes the tag, field, and
* guards to narrow and access the contained value.
*/
export interface Err<E> {
kind: "err";
error: E;
isOk(this: Result<unknown, E>): this is Ok<never>;
isErr(this: Result<unknown, E>): this is Err<E>;
}
/**
* Common interface for accessing the inner values of results. In use, one or
* the other accessor will be of type never. A use of a result will first need
* to narrow via one of the type guard methods, {@method isOk} and {@method
* isErr} to narrow the generics correctly and use the methods of this
* interface.
*/
export interface Unwrap<T, E> {
get value(): T;
get error(): E;
}
/**
* Common interface for all of the useful combinators that allow accessing and
* working with interior values of {@type Result}.
*/
export interface Combinators<T, E> {
/**
* If this result is {@type OK}, return {@param r} otherwise preserve any
* {@type Err}. If you need to run any logic to produce a new value, use
* {@method andThen} instead.
*/
and<U>(r: Result<U, E>): Result<U, E>;
/**
* If this result is {@type OK}, evaluates {@param f} and returns, otherwise
* preserve any {@type Err}. `andThen` is a synonym for `flatMap`.
*/
andThen<U>(f: (t: T) => Result<U, E>): Result<U, E>;
/**
* Variation of {@method andThen} that accepts an asynchronous lambda, useful
* with {@function thenPipeAsync} to compose {@type Result} resolving async
* functions.
*/
andThenAsync<U>(f: (t: T) => AsyncResult<U, E>): AsyncResult<U, E>;
/**
* If this result is {@type Err}, return {@param r} otherwise preserve any
* {@type Ok}. If you need to run any logic to produce a new value, use
* {@method orElse} instead.
*/
or<F>(r: Result<T, F>): Result<T, F>;
/**
* Accept a lambda to execute in case of an error.
*/
orElse<F>(f: (e: E) => Result<T, F>): Result<T, F>;
/**
* Accept an async lambda to execute in case of an error.
*/
orElseAsync<F>(f: (e: E) => AsyncResult<T, F>): AsyncResult<T, F>;
/**
* Synonym for map, useful at the end of a chain of {@method andThen} calls
* to adjust the final value before unwrapping.
*/
yield<U>(f: (t: T) => U): Result<U, E>;
/**
* Variation of yield that accepts an asynchronous closure.
*/
yieldAsync<U>(f: (t: T) => Promise<U>): AsyncResult<U, E>;
/**
* A flat map for the error of this {@type Result}.
*/
yieldErr<F>(f: (e: E) => F): Result<T, F>;
/**
* If this result is {@type Err}, return an {@type Ok} containing {@param u},
* otherwise execute {@param f}. If you need to run some logic to produce the
* value for the argument, {@param u}, use {@method yieldOrElse} instead.
*/
yieldOr<U>(u: U, f: (t: T) => U): Result<U, E>;
/**
* Accept a lambda to execute and produce a default value in case of an
* error.
*/
yieldOrElse<U>(u: (e: E) => U, f: (t: T) => U): Result<U, E>;
}
/**
* Top type for our Try type.
*
* This is based on the approach explained in
* https://www.jmcelwa.in/posts/rust-like-enums/ and after several attempts is
* the only way to get the type guard methods of the variants to narrow
* correctly for all use cases.
*
* Using never here with {@type Unwrap} works to ensure only the correct inner
* values are accessible and avoids the problems that arise using
* never/any/unknown directly on the implementation classes.
*/
export type Result<T, E> = (
| (Ok<T> & Unwrap<T, never>)
| (Err<E> & Unwrap<never, E>)
) &
Combinators<T, E>;
export function Ok<T, E>(t: T): Result<T, E> {
return new Result.Ok(t);
}
export function Err<T, E>(e: E): Result<T, E> {
return new Result.Err(e);
}
/**
* An alias that makes reading async function signatures that return promises a
* bit easier.
*/
export type AsyncResult<T, E> = Promise<Result<T, E>>;
/**
* This namespace allows the concrete implementations to have the same name as
* their respective key interfaces. Callers should not need to work with the
* namespace or its contained classes directly.
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Result {
export class Ok<T> implements Ok<T>, Unwrap<T, never>, Combinators<T, never> {
kind = "ok" as const;
constructor(readonly _value: T) {}
isOk(this: Result<T, unknown>): this is Ok<T> {
return this.kind === "ok";
}
isErr(this: Result<T, unknown>): this is Err<never> {
return !this.isOk();
}
and<U, E>(r: Result<U, E>): Result<U, E> {
return combinators.and<T, U, E>(this, r);
}
andThen<U, E>(f: (t: T) => Result<U, E>): Result<U, E> {
return f(this.value);
}
async andThenAsync<U, E>(
f: (t: T) => AsyncResult<U, E>,
): AsyncResult<U, E> {
return await f(this.value);
}
or<F>(r: Result<T, F>): Result<T, F> {
return combinators.or(this, r);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
orElse<F>(_: (e: never) => Result<T, F>): Result<T, F> {
return this;
}
async orElseAsync<F>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: (e: never) => AsyncResult<T, F>,
): AsyncResult<T, F> {
return this;
}
yield<U, E>(f: (t: T) => U): Result<U, E> {
return combinators.mapSync<T, U, E>(this, f);
}
async yieldAsync<U, E>(f: (t: T) => Promise<U>): AsyncResult<U, E> {
return await combinators.mapAsync<T, U, E>(this, f);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
yieldErr<E, F>(_: (e: E) => F): Result<T, F> {
return this;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
yieldOr<U>(_: U, f: (t: T) => U): Result<U, never> {
return this.yield(f);
}
yieldOrElse<U>(d: (e: never) => U, f: (t: T) => U): Result<U, never> {
return this.yieldOr(d(this.error), f);
}
get value(): T {
return this._value;
}
get error(): never {
throw new Error(
"Cannot retrieve error value from OK! Has this result been narrowed using isOk/isErr?",
);
}
}
export class Err<E>
implements Err<E>, Unwrap<never, E>, Combinators<never, E>
{
kind = "err" as const;
constructor(readonly _error: E) {}
isOk(this: Result<unknown, E>): this is Ok<never> {
return !this.isErr();
}
isErr(this: Result<unknown, E>): this is Err<E> {
return this.kind === "err";
}
and<U>(r: Result<U, E>): Result<U, E> {
return combinators.and(this, r);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
andThen<T, U>(_: (t: T) => Result<U, E>): Result<U, E> {
return this;
}
async andThenAsync<T, U>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: (t: T) => AsyncResult<U, E>,
): AsyncResult<U, E> {
return this;
}
or<T, F>(r: Result<T, F>): Result<T, F> {
return combinators.or<T, E, F>(this, r);
}
orElse<F>(f: (e: E) => Result<never, F>): Result<never, F> {
return f(this.error);
}
async orElseAsync<F>(
f: (e: E) => AsyncResult<never, F>,
): AsyncResult<never, F> {
return await f(this.error);
}
yield<T, U>(f: (t: T) => U): Result<U, E> {
return combinators.mapSync(this, f);
}
async yieldAsync<T, U>(f: (t: T) => Promise<U>): AsyncResult<U, E> {
return await combinators.mapAsync(this, f);
}
yieldErr<T, F>(f: (e: E) => F): Result<T, F> {
return new Result.Err(f(this.error));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
yieldOr<U>(u: U, _: (t: never) => U): Result<U, E> {
return new Result.Ok(u);
}
yieldOrElse<U>(u: (e: E) => U, f: (t: never) => U): Result<U, E> {
return this.yieldOr(u(this._error), f);
}
get value(): never {
throw new Error(
"Cannot retrieve value from Err! Has this result been narrowed using isOk/isErr?",
);
}
get error(): E {
return this._error;
}
}
}

66
src/pipe.ts Normal file
View file

@ -0,0 +1,66 @@
import { AsyncResult, Result } from ".";
/** @internal */
export function thenPipe<T, U, E>(
f: (t: T) => Result<U, E>,
): (r: Result<T, E>) => Result<U, E> {
return (r: Result<T, E>): Result<U, E> => r.andThen(f);
}
/** @internal */
export function thenPipeAsync<T, U, E>(
f: (t: T) => AsyncResult<U, E>,
): (r: Result<T, E>) => AsyncResult<U, E> {
return async (r: Result<T, E>): AsyncResult<U, E> => await r.andThenAsync(f);
}
/** @internal */
export function yieldPipe<T, U, E>(
f: (t: T) => U,
): (r: Result<T, E>) => Result<U, E> {
return (r: Result<T, E>): Result<U, E> => r.yield(f);
}
/** @internal */
export function yieldOrPipe<T, U, E>(
u: U,
f: (t: T) => U,
): (r: Result<T, E>) => Result<U, E> {
return (r: Result<T, E>): Result<U, E> => r.yieldOr(u, f);
}
/** @internal */
export function yieldOrElsePipe<T, U, E>(
u: (e: E) => U,
f: (t: T) => U,
): (r: Result<T, E>) => Result<U, E> {
return (r: Result<T, E>): Result<U, E> => r.yieldOrElse(u, f);
}
/** @internal */
export function elsePipe<T, E, F>(
f: (e: E) => Result<T, F>,
): (r: Result<T, E>) => Result<T, F> {
return (r: Result<T, E>): Result<T, F> => r.orElse(f);
}
/** @internal */
export function elsePipeAsync<T, E, F>(
f: (e: E) => AsyncResult<T, F>,
): (r: Result<T, E>) => AsyncResult<T, F> {
return async (r: Result<T, E>): AsyncResult<T, F> => r.orElseAsync(f);
}
/** @internal */
export function yieldPipeAsync<T, U, E>(
f: (t: T) => Promise<U>,
): (r: Result<T, E>) => AsyncResult<U, E> {
return async (r: Result<T, E>): AsyncResult<U, E> => r.yieldAsync(f);
}
/** @internal */
export function errPipe<T, E, F>(
f: (e: E) => F,
): (r: Result<T, E>) => Result<T, F> {
return (r: Result<T, E>): Result<T, F> => r.yieldErr(f);
}

117
src/resultify.ts Normal file
View file

@ -0,0 +1,117 @@
import { AsyncResult, Result } from ".";
import {
elsePipe,
elsePipeAsync,
errPipe,
thenPipe,
thenPipeAsync,
yieldOrElsePipe,
yieldOrPipe,
yieldPipe,
yieldPipeAsync,
} from "./pipe";
export type Resultified<T, E> = Promise<Result<T, E>> & {
and<U>(r: Result<U, E>): Resultified<U, E>;
andThen<U>(f: (t: T) => Result<U, E>): Resultified<U, E>;
andThenAsync<U>(f: (t: T) => AsyncResult<U, E>): Resultified<U, E>;
or<F>(r: Result<T, F>): Resultified<T, F>;
orElse<F>(f: (e: E) => Result<T, F>): Resultified<T, F>;
orElseAsync<F>(f: (e: E) => AsyncResult<T, F>): Resultified<T, F>;
yield<U>(f: (t: T) => U): Resultified<U, E>;
yieldAsync<U>(f: (t: T) => Promise<U>): Resultified<U, E>;
yieldErr<F>(f: (e: E) => F): Resultified<T, F>;
yieldOr<U>(u: U, f: (t: T) => U): Resultified<U, E>;
yieldOrElse<U>(u: (e: E) => U, f: (t: T) => U): Resultified<U, E>;
};
/**
* Consumes a {@type Result<T, E>} or {@type Promise<Result<T, E>>} and patches
* it so that the methods of {@type Result<T, E>} can be called on the outer
* {@type Promise}. If the argument is a {@type Result<T, E>}, this function
* wraps it in an immediately resolved {@type Promise}; this behavior
* simplifies starting a call chain with a call to a synchronous, {@type
* Result} returning function. This function will not re-patch an already
* modified promise meaning it is safe to call again with the same promise if
* unsure.
*/
export function resultify<T, E>(
resultOrPromise: Result<T, E> | Promise<Result<T, E>>,
): Resultified<T, E> {
const promise = intoPromise(resultOrPromise);
const resultified = promise as Resultified<T, E>;
if (!resultOrPromise || !resultified) {
throw new Error(
"Promise was undefined or null! If this error occurs during testing, make sure your mocks return a Promise<Result>",
);
}
if (resultified.andThen && typeof resultified.andThen === "function") {
return resultified;
}
resultified.and = <U>(r: Result<U, E>): Resultified<U, E> =>
resultify(promise.then(() => r));
resultified.andThen = <U>(f: (t: T) => Result<U, E>): Resultified<U, E> =>
resultify(promise.then(thenPipe(f)));
resultified.andThenAsync = <U>(
f: (t: T) => AsyncResult<U, E>,
): Resultified<U, E> => resultify(promise.then(thenPipeAsync(f)));
resultified.or = <F>(r: Result<T, F>): Resultified<T, F> =>
resultify(promise.then(() => r));
resultified.orElse = <F>(f: (e: E) => Result<T, F>): Resultified<T, F> =>
resultify(promise.then(elsePipe(f)));
resultified.orElseAsync = <F>(
f: (e: E) => AsyncResult<T, F>,
): Resultified<T, F> => resultify(promise.then(elsePipeAsync(f)));
resultified.yield = <U>(f: (t: T) => U): Resultified<U, E> =>
resultify(promise.then(yieldPipe(f)));
resultified.yieldAsync = <U>(f: (t: T) => Promise<U>): Resultified<U, E> =>
resultify(promise.then(yieldPipeAsync(f)));
resultified.yieldErr = <F>(f: (e: E) => F): Resultified<T, F> =>
resultify(promise.then(errPipe(f)));
resultified.yieldOr = <U>(u: U, f: (t: T) => U): Resultified<U, E> =>
resultify(promise.then(yieldOrPipe(u, f)));
resultified.yieldOrElse = <U>(
u: (e: E) => U,
f: (t: T) => U,
): Resultified<U, E> => resultify(promise.then(yieldOrElsePipe(u, f)));
return resultified;
}
function intoPromise<T, E>(
resultOrPromise: Result<T, E> | Promise<Result<T, E>>,
): Promise<Result<T, E>> {
return isResult(resultOrPromise)
? Promise.resolve(resultOrPromise)
: resultOrPromise;
}
function isResult<T, E>(
resultOrPromise: Result<T, E> | Promise<Result<T, E>>,
): resultOrPromise is Result<T, E> {
return (
resultOrPromise instanceof Result.Ok ||
resultOrPromise instanceof Result.Err
);
}

43
src/unit.ts Normal file
View file

@ -0,0 +1,43 @@
import { Err, Ok, Result } from ".";
/**
* Convert an exception throwing function into a {@type Result} returning one.
*/
export function trySync<T, E>(f: () => T, e: (e: unknown) => E): Result<T, E> {
try {
return Ok(f());
} catch (cause) {
return Err(e(cause));
}
}
/**
* Convert an exception throwing async function into a {@type Result} returning
* one.
*/
export async function tryAsync<T, E>(
f: () => Promise<T>,
e: (e: unknown) => E,
): Promise<Result<T, E>> {
try {
return Ok(await f());
} catch (cause) {
return Err(e(cause));
}
}
export function passOrYield<E>(
// eslint-disable-next-line @typescript-eslint/ban-types
errorClass: Function,
f: (error: unknown) => E,
): (error: unknown) => E {
return (error: unknown) => {
if (
error instanceof errorClass ||
(error instanceof Error && error.name === errorClass.name)
) {
return error as E;
}
return f(error);
};
}

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"isolatedModules": true,
"module": "commonjs",
"moduleResolution": "node",
"skipLibCheck": true,
"strict": true,
"target": "esNext",
"outDir": "./dist/",
"rootDir": "./src/"
},
"exclude": ["dist"],
"include": ["./src/**/*.ts"]
}

7890
yarn.lock Normal file

File diff suppressed because it is too large Load diff