From fbd3d6ba43f95822ade992faa80fc271ce7c3442 Mon Sep 17 00:00:00 2001 From: Derk Bell Date: Mon, 2 Sep 2024 09:53:22 +0200 Subject: [PATCH 1/4] mapError: sync (Result) part --- src/result.test.ts | 141 +++++++++++++++++++++++++++++++++++++++++++++ src/result.ts | 68 ++++++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/src/result.test.ts b/src/result.test.ts index a024537..44c20dd 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -1198,6 +1198,147 @@ describe("Result", () => { }); }); + describe("mapError", () => { + it("maps an encapsulated error value to a next result using a transform function", () => { + const result: Result = Result.error( + new CustomError("TEST_ERROR"), + ); + const nextResult = result.mapError( + (error) => new CustomError(`FROM_${error.message}`), + ); + expectTypeOf(nextResult).toEqualTypeOf>(); + Result.assertError(nextResult); + expect(nextResult.error).toBeInstanceOf(CustomError); + expect(nextResult.error.message).toBe("FROM_TEST_ERROR"); + expect(result).not.toBe(nextResult); + }); + + it("maps an encapsulated error value to an async-result using an async transform function", async () => { + const result: Result = Result.error( + new CustomError("TEST_ERROR"), + ); + const nextAsyncResult = result.mapError( + async (error) => new CustomError(`FROM_${error.message}`), + ); + expectTypeOf(nextAsyncResult).toEqualTypeOf< + AsyncResult + >(); + expect(nextAsyncResult).toBeInstanceOf(AsyncResult); + + const nextResult = await nextAsyncResult; + + Result.assertError(nextResult); + expect(nextResult.error).toBeInstanceOf(CustomError); + expect(nextResult.error.message).toBe("FROM_TEST_ERROR"); + }); + + it("lets you map over an encapsulated succes value by simply ignoring the transform function and returning the success result", () => { + const result = Result.ok(2) as Result; + const nextResult = result.mapError( + (error) => new CustomError("TEST_ERROR", { cause: error }), + ); + + expectTypeOf(nextResult).toEqualTypeOf>(); + expect(result).toBe(nextResult); + Result.assertOk(nextResult); + expect(nextResult.value).toEqual(2); + }); + + it("accounts for the async transform function even when it is a failed result", async () => { + const result = Result.ok(2) as Result; + const nextAsyncResult = result.mapError( + async (error) => new CustomError("TEST_ERROR", { cause: error }), + ); + + expectTypeOf(nextAsyncResult).toEqualTypeOf< + AsyncResult + >(); + expect(nextAsyncResult).toBeInstanceOf(AsyncResult); + + const nextResult = await nextAsyncResult; + Result.assertOk(nextResult); + expect(nextResult.value).toEqual(2); + }); + + it("flattens a returning result from the transformation", () => { + const result: Result = Result.error( + new CustomError("INNER_ERROR"), + ); + const nextResult = result.mapError((error) => + Result.error(new CustomError("TEST_ERROR", { cause: error })), + ); + expectTypeOf(nextResult).toEqualTypeOf>(); + Result.assertError(nextResult); + expect(nextResult.error.message).toBe("TEST_ERROR"); + expect(result).not.toBe(nextResult); + }); + + it("flattens a returning result from the async transformation", async () => { + const result: Result = Result.error( + new CustomError("INNER_ERROR"), + ); + const nextAsyncResult = result.mapError(async (error) => + Result.error(new CustomError("TEST_ERROR", { cause: error })), + ); + + expectTypeOf(nextAsyncResult).toEqualTypeOf< + AsyncResult + >(); + + const nextResult = await nextAsyncResult; + + Result.assertError(nextResult); + expect(nextResult.error.message).toBe("TEST_ERROR"); + expect(result).not.toBe(nextResult); + }); + + it("flattens a returning async-result from the transformation", async () => { + const result: Result = Result.error( + new CustomError("INNER_ERROR"), + ); + const otherAsyncResult = Result.fromAsyncCatching( + Promise.reject(new CustomError("OTHER_ERROR")), + ); + + const nextAsyncResult = result.mapError(() => otherAsyncResult); + expectTypeOf(nextAsyncResult).toEqualTypeOf< + AsyncResult + >(); + + const nextResult = await nextAsyncResult; + + Result.assertError(nextResult); + expect(nextResult.error.message).toBe("OTHER_ERROR"); + expect(result).not.toBe(nextResult); + }); + + it("does not track errors thrown inside the transformation function", () => { + expect(() => + Result.error(new CustomError()).mapError((_error) => { + throw new CustomError("THROWN_ERROR"); + }), + ).to.throw(CustomError); + }); + + it("will convert a success into an async-result when an async transform function was given", async () => { + const result = Result.ok(2) as Result; + + const asyncResult = result.mapError( + async (_error) => new CustomError("TEST_ERROR"), + ); + + expectTypeOf(asyncResult).toEqualTypeOf< + AsyncResult + >(); + + expect(asyncResult).toBeInstanceOf(AsyncResult); + + const resolvedAsyncResult = await asyncResult; + Result.assertOk(resolvedAsyncResult); + expect(resolvedAsyncResult.value).toBe(2); + }); + }); + describe("mapCatching", () => { it("does track errors thrown inside the transformation function", () => { const fn = () => diff --git a/src/result.ts b/src/result.ts index d39d74f..845527f 100644 --- a/src/result.ts +++ b/src/result.ts @@ -457,6 +457,14 @@ export class AsyncResult extends Promise> { return new AsyncResult((resolve) => resolve(Result.error(error))); } + static errorFromPromise(promise: AnyPromise) { + return new AsyncResult((resolve) => { + promise.then((value) => + resolve(Result.isResult(value) ? value : Result.error(value)), + ); + }); + } + /** * @internal */ @@ -922,6 +930,54 @@ export class Result { : Result; } + /** + * Transforms the value of an error result using the {@link transform} callback. + * The {@link transform} callback can also return other {@link Result} or {@link AsyncResult} instances, + * which will be returned as-is (the `Ok` types will be merged). + * The operation will be ignored if the result represents a success. + * + * @param transform callback function to transform the value of the result. The callback can be async as well. + * @returns a new {@linkcode Result} instance with the transformed error, or a new {@linkcode AsyncResult} instance + * if the transform function is async. + * + * > [!NOTE] + * > Any exceptions that might be thrown inside the {@link transform} callback are not caught, so it is your responsibility + * > to handle these exceptions. Please refer to {@linkcode Result.mapCatching} for a version that catches exceptions + * > and encapsulates them in a failed result. + * + * @example + * transforming the value of a result + * ```ts + * declare const result: Result; + * + * const transformed = result.mapError((error) => new CustomError('Custom error message')); // Result + * ``` + * + * + * @example + * doing an async transformation + * ```ts + * declare const result: Result; + * + * const transformed = result.mapError(async (error) => new CustomError(error.message)); // AsyncResult + * ``` + */ + mapError(transform: (error: Err) => ReturnType) { + return ( + this.failure + ? Result.runError(() => transform(this._error)) + : isAsyncFn(transform) + ? AsyncResult.ok(this._value) + : this + ) as ReturnType extends Promise + ? PromiseValue extends Result + ? AsyncResult + : AsyncResult + : ReturnType extends Result + ? Result + : Result; + } + /** * Like {@linkcode Result.map} it transforms the value of a successful result using the {@link transform} callback. * In addition, it catches any exceptions that might be thrown inside the {@link transform} callback and encapsulates them @@ -1087,6 +1143,18 @@ export class Result { return Result.isResult(returnValue) ? returnValue : Result.ok(returnValue); } + private static runError(fn: AnyFunction): AnyResult | AnyAsyncResult { + const returnValue = fn(); + + if (isPromise(returnValue)) { + return AsyncResult.errorFromPromise(returnValue); + } + + return Result.isResult(returnValue) + ? returnValue + : Result.error(returnValue); + } + private static allInternal( items: any[], opts: { catching: boolean }, From 02c265892ad55d4a72d03697b3f880a706a1a241 Mon Sep 17 00:00:00 2001 From: Derk Bell Date: Mon, 2 Sep 2024 13:55:51 +0200 Subject: [PATCH 2/4] mapCatching: optional errorTransform function --- src/result.test.ts | 16 ++++++++++++++++ src/result.ts | 12 ++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/result.test.ts b/src/result.test.ts index 44c20dd..16201a8 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -1376,6 +1376,22 @@ describe("Result", () => { Result.assertError(nextResult); expect(spy).not.toHaveBeenCalled(); }); + + it("transforms thrown errors when an errorTransoform function is defined", async () => { + const result: Result = await Result.ok( + 2, + ).mapCatching( + async (): Promise => { + throw new Error("TEST_ERROR"); + }, + (_error) => { + return new CustomError(); + }, + ); + + Result.assertError(result); + expect(result.error).toBeInstanceOf(CustomError); + }); }); describe("recover", () => { diff --git a/src/result.ts b/src/result.ts index 845527f..24ec0ab 100644 --- a/src/result.ts +++ b/src/result.ts @@ -987,9 +987,17 @@ export class Result { * @returns a new {@linkcode Result} instance with the transformed value, or a new {@linkcode AsyncResult} instance * if the transform function is async. */ - mapCatching(transform: (value: Value) => ReturnType) { + mapCatching( + transform: (value: Value) => ReturnType, + errorTransform?: (error: unknown) => ErrorType, + ) { return ( - this.success ? Result.try(() => transform(this._value)) : this + this.success + ? Result.try( + () => transform(this._value), + (error: any) => (errorTransform ? errorTransform(error) : error), + ) + : this ) as ReturnType extends Promise ? PromiseValue extends Result ? AsyncResult From 7d754e5d1b26a96453ceef494edaf6e4fa5af97f Mon Sep 17 00:00:00 2001 From: Derk Bell Date: Mon, 2 Sep 2024 15:20:56 +0200 Subject: [PATCH 3/4] mapError: async (AsyncResult) variant --- src/result.test.ts | 113 +++++++++++++++++++++++++++++++++++++++++++++ src/result.ts | 35 ++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/src/result.test.ts b/src/result.test.ts index 16201a8..4651551 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -2118,6 +2118,119 @@ describe("AsyncResult", () => { }); }); + describe("mapError", () => { + it("maps an encapsulated error value to a next result using a transform function", async () => { + const result: AsyncResult = AsyncResult.error( + new CustomError("TEST_ERROR"), + ); + const nextResult = result.mapError( + (error) => new CustomError("MAPPED_ERROR", { cause: error }), + ); + expectTypeOf(nextResult).toEqualTypeOf< + AsyncResult + >(); + const resolvedResult = await nextResult; + Result.assertError(resolvedResult); + expect(resolvedResult.error.message).toBe("MAPPED_ERROR"); + }); + + it("maps an encapsulated error value to an async-result using an async transform function", async () => { + const result = AsyncResult.error(new CustomError()); + const nextAsyncResult = result.mapError( + async (error) => new CustomError("MAPPED_ERROR", { cause: error }), + ); + expectTypeOf(nextAsyncResult).toEqualTypeOf< + AsyncResult + >(); + expect(nextAsyncResult).toBeInstanceOf(AsyncResult); + + const nextResult = await nextAsyncResult; + + Result.assertError(nextResult); + expect(nextResult.error.message).toBe("MAPPED_ERROR"); + }); + + it("lets you map over an encapsulated success value by simply ignoring the transform function and returning the success result", async () => { + const result = AsyncResult.ok(2) as AsyncResult; + + const spy = vi.fn(); + const nextResult = result.mapError((_error) => { + spy(); + return new CustomError(); + }); + + expectTypeOf(nextResult).toEqualTypeOf< + AsyncResult + >(); + expect(spy).not.toHaveBeenCalled(); + + // Async result will always return a new instance + expect(result).not.toBe(nextResult); + + const resolvedResult = await nextResult; + Result.assertOk(resolvedResult); + expect(resolvedResult.value).toEqual(2); + }); + + it("flattens a returning result from the transformation", async () => { + const result = AsyncResult.error(new CustomError("TEST_ERROR")); + const nextResult = result.mapError((error) => + Result.error(new CustomError(`FROM_${error.message}`)), + ); + expectTypeOf(nextResult).toEqualTypeOf< + AsyncResult + >(); + + const resolvedResult = await nextResult; + Result.assertError(resolvedResult); + expect(resolvedResult.error.message).toBe("FROM_TEST_ERROR"); + expect(result).not.toBe(nextResult); + }); + + it("flattens a returning result from the async transformation", async () => { + const result = AsyncResult.error(new CustomError("TEST_ERROR")); + const nextAsyncResult = result.mapError( + async (error) => new CustomError(`FROM_${error.message}`), + ); + + expectTypeOf(nextAsyncResult).toEqualTypeOf< + AsyncResult + >(); + + const nextResult = await nextAsyncResult; + + Result.assertError(nextResult); + expect(nextResult.error.message).toBe("FROM_TEST_ERROR"); + expect(result).not.toBe(nextResult); + }); + + it("flattens a returning async-result from the transformation", async () => { + const result = AsyncResult.error(new CustomError("TEST_ERROR")); + const otherAsyncResult = Result.fromAsyncCatching( + Promise.reject(new CustomError("OTHER_ERROR")), + ); + + const nextAsyncResult = result.mapError(() => otherAsyncResult); + expectTypeOf(nextAsyncResult).toEqualTypeOf< + AsyncResult + >(); + + const nextResult = await nextAsyncResult; + + Result.assertError(nextResult); + expect(nextResult.error.message).toBe("OTHER_ERROR"); + expect(result).not.toBe(nextResult); + }); + + it("does not track errors thrown inside the transformation function", async () => { + await expect(() => + AsyncResult.error(new CustomError()).mapError((): Error => { + throw new CustomError(); + }), + ).rejects.toThrow(CustomError); + }); + }); + describe("mapCatching", () => { it("does track errors thrown inside the transformation function", async () => { const result = await AsyncResult.ok(2).mapCatching((): number => { diff --git a/src/result.ts b/src/result.ts index 24ec0ab..fad8196 100644 --- a/src/result.ts +++ b/src/result.ts @@ -351,6 +351,41 @@ export class AsyncResult extends Promise> { : AsyncResult; } + mapError(transform: (error: Err) => ReturnType) { + return new AsyncResult((resolve, reject) => + this.then((result) => { + if (result.isError()) { + try { + const returnValue = transform((result as { error: Err }).error); + if (isPromise(returnValue)) { + returnValue + .then((value) => + resolve(Result.isResult(value) ? value : Result.error(value)), + ) + .catch(reject); + } else { + resolve( + Result.isResult(returnValue) + ? returnValue + : Result.error(returnValue), + ); + } + } catch (error) { + reject(error); + } + } else { + resolve(result); + } + }), + ) as ReturnType extends Promise + ? PromiseValue extends Result + ? AsyncResult + : AsyncResult + : ReturnType extends Result + ? AsyncResult + : AsyncResult; + } + /** * Like {@linkcode AsyncResult.map} it transforms the value of a successful result using the {@link transform} callback. * In addition, it catches any exceptions that might be thrown inside the {@link transform} callback and encapsulates them From 6471d77f7c653944cc1ca0e58aba45674d507411 Mon Sep 17 00:00:00 2001 From: Derk Bell Date: Mon, 2 Sep 2024 15:31:37 +0200 Subject: [PATCH 4/4] mapError: async (AsyncResult) variant --- src/result.test.ts | 16 +++++++++++++++- src/result.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/result.test.ts b/src/result.test.ts index 4651551..a7d5887 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -1377,7 +1377,7 @@ describe("Result", () => { expect(spy).not.toHaveBeenCalled(); }); - it("transforms thrown errors when an errorTransoform function is defined", async () => { + it("transforms thrown errors when an errorTransform function is defined", async () => { const result: Result = await Result.ok( 2, ).mapCatching( @@ -2250,6 +2250,20 @@ describe("AsyncResult", () => { Result.assertError(result); expect(result.error).toBeInstanceOf(CustomError); }); + + it("transforms thrown errors when an errorTransform function is defined", async () => { + const result = await AsyncResult.ok(2).mapCatching( + async (): Promise => { + throw new Error("TEST_ERROR"); + }, + (_error) => { + return new CustomError(); + }, + ); + + Result.assertError(result); + expect(result.error).toBeInstanceOf(CustomError); + }); }); describe("recover", () => { diff --git a/src/result.ts b/src/result.ts index fad8196..425ea8e 100644 --- a/src/result.ts +++ b/src/result.ts @@ -351,6 +351,37 @@ export class AsyncResult extends Promise> { : AsyncResult; } + /** + * Transforms the value of an error result using the {@link transform} callback. + * The {@link transform} callback can also return other {@link Result} or {@link AsyncResult} instances, + * which will be returned as-is (the `Error` types will be merged). + * The operation will be ignored if the result represents a success. + * + * @param transform callback function to transform the error of the result. The callback can be async as well. + * @returns a new {@linkcode AsyncResult} instance with the transformed error + * + * > [!NOTE] + * > Any exceptions or rejections that might be thrown inside the {@link transform} callback are not caught, so it is your responsibility + * > to handle these exceptions. Please refer to {@linkcode AsyncResult.mapCatching} for a version that catches exceptions + * > and encapsulates them in a failed result. + * + * @example + * transforming the value of a result + * ```ts + * declare const result: AsyncResult; + * + * const transformed = result.mapError((error) => new CustomError()); // AsyncResult + * ``` + * + * @example + * returning a result instance + * ```ts + * declare const result: AsyncResult; + * declare function createCustomError(error: unknown): Result; + * + * const transformed = result.mapError((error) => createCustomError(error)); // AsyncResult + * ``` + */ mapError(transform: (error: Err) => ReturnType) { return new AsyncResult((resolve, reject) => this.then((result) => { @@ -392,13 +423,19 @@ export class AsyncResult extends Promise> { * in a failed result. * * @param transform callback function to transform the value of the result. The callback can be async as well. + * @param errorTransform optional callback function to transform the value of the error. The callback can be async as well. * @returns a new {@linkcode AsyncResult} instance with the transformed value */ - mapCatching(transform: (value: Value) => ReturnType) { + mapCatching( + transform: (value: Value) => ReturnType, + errorTransform?: (error: unknown) => ErrorType, + ) { return new AsyncResult((resolve) => { this.map(transform) .then((result: AnyResult) => resolve(result)) - .catch((error: unknown) => resolve(Result.error(error))); + .catch((error: unknown) => + resolve(Result.error(errorTransform ? errorTransform(error) : error)), + ); }) as ReturnType extends Promise ? PromiseValue extends Result ? AsyncResult @@ -1019,6 +1056,7 @@ export class Result { * in a failed result. * * @param transform callback function to transform the value of the result. The callback can be async as well. + * @param errorTransform optional callback function to transform the value of the error. The callback can be async as well. * @returns a new {@linkcode Result} instance with the transformed value, or a new {@linkcode AsyncResult} instance * if the transform function is async. */