diff --git a/packages/utility-features/utilities/index.ts b/packages/utility-features/utilities/index.ts index 59f90d3da5..223dcf89f1 100644 --- a/packages/utility-features/utilities/index.ts +++ b/packages/utility-features/utilities/index.ts @@ -42,3 +42,5 @@ export * from "./src/types"; export * from "./src/union-env-path"; export * from "./src/wait"; export * from "./src/with-concurrency-limit"; +export * from "./src/fatality-of-call/call-result/call-result"; +export * from "./src/fatality-of-call/with-thrown-failures/with-thrown-failures"; diff --git a/packages/utility-features/utilities/src/fatality-of-call/call-result/call-result.test.ts b/packages/utility-features/utilities/src/fatality-of-call/call-result/call-result.test.ts new file mode 100644 index 0000000000..ca16f74d5f --- /dev/null +++ b/packages/utility-features/utilities/src/fatality-of-call/call-result/call-result.test.ts @@ -0,0 +1,50 @@ +import { + CallResult, + callWasFailure, + callWasSuccessful, + getFailure, + getSuccess, +} from "./call-result"; + +describe("call-result", () => { + it("given successful call, narrows type of response", () => { + const someSuccess: CallResult = getSuccess("some-success"); + + if (callWasSuccessful(someSuccess)) { + expect(someSuccess.response).toBe("some-success"); + } + + expect.assertions(1); + }); + + it("given successful call, call is not failure", () => { + const actual = callWasFailure(getSuccess("some-success")); + + expect(actual).toBe(false); + }); + + it("given unsuccessful call, narrows type of error", () => { + const someFailure: CallResult = getFailure( + "some-error-code", + "some-cause" + ); + + if (callWasFailure(someFailure)) { + expect(someFailure.error).toEqual({ + code: "some-error-code", + cause: "some-cause", + message: "some-cause", + }); + } + + expect.assertions(1); + }); + + it("given unsuccessful call, call is not successful", () => { + const actual = callWasSuccessful( + getFailure("some-error-code", "some-cause") + ); + + expect(actual).toBe(false); + }); +}); diff --git a/packages/utility-features/utilities/src/fatality-of-call/call-result/call-result.ts b/packages/utility-features/utilities/src/fatality-of-call/call-result/call-result.ts new file mode 100644 index 0000000000..a184e0372a --- /dev/null +++ b/packages/utility-features/utilities/src/fatality-of-call/call-result/call-result.ts @@ -0,0 +1,52 @@ +import { isString } from "lodash/fp"; + +export type CallFailure = { + callWasSuccessful: false; + error: { code: string; message: string; cause: unknown }; +}; + +export type CallSuccess = Response extends void + ? { callWasSuccessful: true; response?: undefined } + : { callWasSuccessful: true; response: Response }; + +export type CallResult = CallSuccess | CallFailure; + +export type AsyncCallResult = Promise>; + +export type AsyncCallSuccess = Promise>; + +export const getSuccess = (response: T) => ({ + callWasSuccessful: true as const, + response, +}); + +const getErrorMessage = (cause: unknown) => { + if (isString(cause)) { + return cause; + } + + const causeObject = cause as any; + + if (causeObject.message) { + return causeObject.message; + } + + return undefined; +}; + +export const getFailure = (errorCode: string, errorCause: unknown) => ({ + callWasSuccessful: false as const, + error: { + code: errorCode, + cause: errorCause, + message: getErrorMessage(errorCause), + }, +}); + +export const callWasSuccessful = ( + callResult: CallResult +): callResult is CallSuccess => callResult.callWasSuccessful; + +export const callWasFailure = ( + callResult: CallResult +): callResult is CallFailure => !callResult.callWasSuccessful; diff --git a/packages/utility-features/utilities/src/fatality-of-call/with-thrown-failures/with-thrown-failures.test.ts b/packages/utility-features/utilities/src/fatality-of-call/with-thrown-failures/with-thrown-failures.test.ts new file mode 100644 index 0000000000..85cdc8d8f9 --- /dev/null +++ b/packages/utility-features/utilities/src/fatality-of-call/with-thrown-failures/with-thrown-failures.test.ts @@ -0,0 +1,191 @@ +import { AsyncCallResult, getFailure, getSuccess } from "../call-result/call-result"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; + +import { + withThrownFailures, + withThrownFailuresUnless, +} from "./with-thrown-failures"; + +type TestCallResult = AsyncCallResult; + +describe("with-thrown-failures", () => { + let toBeDecoratedMock: AsyncFnMock<() => TestCallResult>; + let actualPromise: TestCallResult; + let toBeDecorated: (arg1: string, arg2: string) => TestCallResult; + + beforeEach(() => { + toBeDecoratedMock = asyncFn(); + toBeDecorated = toBeDecoratedMock; + }); + + describe("given a function with general error handling, when called", () => { + beforeEach(() => { + const decorated = withThrownFailures(toBeDecorated); + + actualPromise = decorated("some-arg", "some-other-arg"); + }); + + it("calls the underlying function", () => { + expect(toBeDecoratedMock).toHaveBeenCalledWith( + "some-arg", + "some-other-arg" + ); + }); + + it("when call resolves as success, resolves as so", async () => { + await toBeDecoratedMock.resolve(getSuccess("some-success")); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess("some-success")); + }); + + it("when call resolves with failed call result with string as cause, throws", () => { + toBeDecoratedMock.resolve(getFailure("some-error-code", "some-cause")); + + return expect(actualPromise).rejects.toThrow( + "Error(some-error-code): some-cause" + ); + }); + + it("when call resolves with failed call result containing message for cause of error, throws", () => { + const someCause = { message: "some-cause" }; + toBeDecoratedMock.resolve(getFailure("some-error-code", someCause)); + + return expect(actualPromise).rejects.toThrow( + "Error(some-error-code): some-cause" + ); + }); + + it("when call resolves with failed call result with error object as cause, throws original error", () => { + const someError = new Error("some-error"); + toBeDecoratedMock.resolve(getFailure("irrelevant", someError)); + + return expect(actualPromise).rejects.toBe(someError); + }); + + it("when call resolves with failed call result not containing message for cause of error, throws", () => { + const someCause = { some: "value" }; + toBeDecoratedMock.resolve(getFailure("some-error-code", someCause)); + + return expect(actualPromise).rejects.toThrow("Error(some-error-code)"); + }); + + it("when call rejects, throws original error", async () => { + const someError = new Error("some-unrelated-error"); + + toBeDecoratedMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); + + describe("given a function with error handling unless error is specific, when called", () => { + beforeEach(() => { + const errorIsSpecific = (error: { code: string }) => + error.code === "some-specific-failure"; + + const decorated = + withThrownFailuresUnless(errorIsSpecific)(toBeDecorated); + + actualPromise = decorated("some-arg", "some-other-arg"); + }); + + it("calls the underlying function", () => { + expect(toBeDecoratedMock).toHaveBeenCalledWith( + "some-arg", + "some-other-arg" + ); + }); + + it("when call resolves as success, resolves as so", async () => { + await toBeDecoratedMock.resolve(getSuccess("some-success")); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess("some-success")); + }); + + it("when call resolves as unrelated failure, throws", () => { + toBeDecoratedMock.resolve( + getFailure("some-unrelated-failure", "some-cause") + ); + + return expect(actualPromise).rejects.toThrow("some-cause"); + }); + + it("when call resolves as the specific failure, resolves as failure", async () => { + await toBeDecoratedMock.resolve( + getFailure("some-specific-failure", "some-cause") + ); + + const actual = await actualPromise; + + expect(actual).toEqual(getFailure("some-specific-failure", "some-cause")); + }); + + it("when call resolves with failed call result with string as cause, throws", () => { + toBeDecoratedMock.resolve(getFailure("some-error-code", "some-cause")); + + return expect(actualPromise).rejects.toThrow( + "Error(some-error-code): some-cause" + ); + }); + + it("when call resolves with failed call result containing message for cause of error, throws", () => { + const someCause = { message: "some-cause" }; + toBeDecoratedMock.resolve(getFailure("some-error-code", someCause)); + + return expect(actualPromise).rejects.toThrow( + "Error(some-error-code): some-cause" + ); + }); + + it("when call resolves with failed call result with error object as cause, throws original error", () => { + const someError = new Error("some-error"); + toBeDecoratedMock.resolve(getFailure("irrelevant", someError)); + + return expect(actualPromise).rejects.toBe(someError); + }); + + it("when call resolves with failed call result not containing message for cause of error, throws", () => { + const someCause = { some: "value" }; + toBeDecoratedMock.resolve(getFailure("some-error-code", someCause)); + + return expect(actualPromise).rejects.toThrow("Error(some-error-code)"); + }); + }); + + describe("given thrown failures unless specific error is thrown, when called", () => { + beforeEach(() => { + const decorated = withThrownFailuresUnless( + (error) => error.message === "some-specific-error" + )(toBeDecorated); + + actualPromise = decorated("some-arg", "some-other-arg"); + }); + + it("when call resolves as success, resolves as so", async () => { + await toBeDecoratedMock.resolve(getSuccess("some-success")); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess("some-success")); + }); + + it("when call rejects as the specific error, resolves as specific failure", async () => { + const error = new Error("some-specific-error"); + await toBeDecoratedMock.reject(error); + + const actual = await actualPromise; + + expect(actual).toEqual(getFailure("unknown", error)); + }); + + it("when call rejects as some unrelated error, throws", async () => { + toBeDecoratedMock.reject(new Error("some-unrelated-error")); + + return expect(actualPromise).rejects.toThrow("some-unrelated-error"); + }); + }); +}); diff --git a/packages/utility-features/utilities/src/fatality-of-call/with-thrown-failures/with-thrown-failures.ts b/packages/utility-features/utilities/src/fatality-of-call/with-thrown-failures/with-thrown-failures.ts new file mode 100644 index 0000000000..9e80a35593 --- /dev/null +++ b/packages/utility-features/utilities/src/fatality-of-call/with-thrown-failures/with-thrown-failures.ts @@ -0,0 +1,76 @@ +import { + AsyncCallResult, + CallFailure, + CallResult, + callWasFailure, + getFailure, +} from "../call-result/call-result"; + +type ToBeDecorated = ( + ...args: T2 +) => AsyncCallResult; + +export const withThrownFailures = + (toBeDecorated: ToBeDecorated) => + async (...args: T2) => { + const result = await toBeDecorated(...args); + + if (callWasFailure(result)) { + throw getError(result); + } + + return result; + }; + +export const withThrownFailuresUnless = + (...reasonsToNotThrow: ErrorShouldNotThrow[]) => + ( + toBeDecorated: ToBeDecorated + ) => + async (...args: TArgs) => { + let result: CallResult; + + try { + result = await toBeDecorated(...args); + } catch (error) { + const reasonsForNotThrowing = reasonsToNotThrow.filter((reason) => + reason(error) + ); + + if (reasonsForNotThrowing.length) { + return getFailure("unknown", error); + } + + throw error; + } + + const notThrownCall = result; + + if (callWasFailure(notThrownCall)) { + const reasonsForNotThrowing = reasonsToNotThrow.filter((reason) => + reason(notThrownCall.error) + ); + + if (reasonsForNotThrowing.length) { + return notThrownCall; + } + + throw getError(notThrownCall); + } + + return notThrownCall; + }; + +type ErrorShouldNotThrow = (error: any) => boolean; + +const getError = (call: CallFailure) => { + if (call.error.cause instanceof Error) { + return call.error.cause; + } + + if (call.error.message) { + return new Error(`Error(${call.error.code}): ${call.error.message}`); + } + + return new Error(`Error(${call.error.code})`); +};