Initial commit
- Separate out from old mono repository. - Adjust name including new namespace. - Adjust version to pre-1.0
This commit is contained in:
commit
5fe8b52c6c
27 changed files with 10450 additions and 0 deletions
2
.eslintignore
Normal file
2
.eslintignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
10
.eslintrc.js
Normal file
10
.eslintrc.js
Normal 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
29
.gitignore
vendored
Normal 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
8
.prettierignore
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/node_modules/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
*md
|
||||||
|
*bash
|
||||||
|
*py
|
||||||
|
*js
|
||||||
|
*txt
|
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{}
|
363
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
363
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
35
.yarn/plugins/@yarnpkg/plugin-outdated.cjs
vendored
Normal file
35
.yarn/plugins/@yarnpkg/plugin-outdated.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
9
.yarn/plugins/@yarnpkg/plugin-typescript.cjs
vendored
Normal file
9
.yarn/plugins/@yarnpkg/plugin-typescript.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
768
.yarn/releases/yarn-3.1.1.cjs
vendored
Executable file
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
22
.yarnrc.yml
Normal 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
|
120
README.md
Normal file
120
README.md
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
# @caredge/result
|
||||||
|
|
||||||
|
This module implements our error type, `Result<T, E>`. After trying out `@snowfrog/result`, we implemented our 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 Typescripts 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 CarEdge/webhook-smq-bridge.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseContext, BaseError, Result } from "@caredge/error";
|
||||||
|
import { passOrYield, resultify, trySync } from "@caredge/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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
37
jest.config.js
Normal file
37
jest.config.js
Normal 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
45
package.json
Normal 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/*"
|
||||||
|
]
|
||||||
|
}
|
95
src/__test__/guards.test.ts
Normal file
95
src/__test__/guards.test.ts
Normal 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
178
src/__test__/index.test.ts
Normal 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
18
src/__test__/index.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
|
}
|
38
src/__test__/resultify.test.ts
Normal file
38
src/__test__/resultify.test.ts
Normal 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
30
src/__test__/unit.test.ts
Normal 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
105
src/carry.ts
Normal 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
22
src/combinators.ts
Normal 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
67
src/guards.ts
Normal 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
312
src/index.ts
Normal 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
66
src/pipe.ts
Normal 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
117
src/resultify.ts
Normal 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
43
src/unit.ts
Normal 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
20
tsconfig.json
Normal 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"]
|
||||||
|
}
|
Loading…
Reference in a new issue