From 0a0cced2d95e0dd0db7034f94b61deaee4adb6de Mon Sep 17 00:00:00 2001 From: Erik Verweij Date: Mon, 2 Sep 2024 19:37:41 +0200 Subject: [PATCH] adds mapError and transformErrorFn in mapCatching --- readme.md | 102 ++++++++++++++++++++----- src/result.test.ts | 183 +++++++++++++++++++++++++++++++++++++++++++++ src/result.ts | 112 ++++++++++++++++++++++----- 3 files changed, 359 insertions(+), 38 deletions(-) diff --git a/readme.md b/readme.md index 50328c1..5211abd 100644 --- a/readme.md +++ b/readme.md @@ -81,13 +81,6 @@ function readFile(path: string) { ); } -function parseJson(value: string) { - return Result.try( - () => JSON.parse(value), - (error) => new ParseError("Unable to parse JSON", { cause: error }) - ); -} - const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null; @@ -101,21 +94,27 @@ function getConfig(value: unknown) { return Result.error(new ValidationError("Missing or invalid 'name' field")); } if (!value.version || !isString(value.version)) { - return Result.error(new ValidationError("Missing or invalid 'version' field")); + return Result.error( + new ValidationError("Missing or invalid 'version' field") + ); } return Result.ok({ name: value.name, version: value.version }); } const message = await readFile("./config.json") - .map((contents) => parseJson(contents)) + .mapCatching( + (contents) => JSON.parse(contents), + (error) => new ParseError("Unable to parse JSON", { cause: error }) + ) .map((json) => getConfig(json)) .fold( (config) => `Successfully read config: name => ${config.name}, version => ${config.version}`, + (error) => { switch (error.type) { - case "io-error": + case "io-error": return "Please check if the config file exists and is readable"; case "parse-error": return "Please check if the config file contains valid JSON"; @@ -409,12 +408,22 @@ if (result.isOk()) { The chained version is more concise and makes it easier to follow the flow of the program. Moreover, it allows us to _centralize_ error handling at the end of the flow. This is possible because all transformative operations produce new results which carry over any errors that might have occurred earlier in the chain. -#### Transform: `map`, `mapCatching`, `recover`, `recoverCatching` +#### Transform: `map`, `mapCatching`, `recover`, `recoverCatching`, `mapError` Both [`map`](#maptransformfn) and [`recover`](#recoveronfailure) behave very similar in the sense that they transform a result using function provided by the user into a new result. The main difference is that `map` is used to transform a successful result, while `recover` is used to transform a failed result. The difference between the 'catching' variants is that they catch any exceptions that might be thrown inside the transformation function and encapsulate them in a failed result. So why would you not always use the 'catching' variants? It might be useful to make a distinction between exceptions that are expected and unexpected. If you _expect_ an exception to be thrown, like in the case of writing a file to disk, you might want to handle this use case. If you _don't expect_ an exception to be thrown, like in the case of saving something to a database, you might _not_ want to catch the exception and let the exception bubble up or even terminate the application. +There's a subtle difference with `mapCatching` however. It takes an optional second argument which is a function that lets you transform any caught exception that was thrown inside the transformation function. This is useful when you want to provide more context or when you want to wrap the error in a custom error type. + +```ts +readFile("source.txt") + .mapCatching( + (contents) => writeFile("destination.txt", contents.toUpperCase()), + (error) => new IOError("Failed to write file", { cause: error }) + ) +``` + Both `map` and `recover` are very flexible when it comes to the returning value of the transformation function. You can return a literal value, a new result, or even a promise that resolves to a value or a result. Other similar result-like libraries might have specific methods for each of thee use cases (e.g. `flatMap`, `chain`, etc.) and can be considered more strict. However, we like the approach of a smaller API surface with more flexibility. All transformations below produce the same type of result (`Result`, with the exception of the async transformations which produce an `AsyncResult`): @@ -442,6 +451,15 @@ persistInDB(item).recover(() => persistLocally(item)); // Result Note that after a recovery, any previous errors that might have occurred are _forgotten_. This is because when using `recover` you are essentially starting with a clean slate. In the example above we can assume that the `DbError` has been taken care of and therefore it has been removed from the final result. `IOError` on te other hand is still a possibility because it might occur after the recovery. +Lastly, you can use `mapError` to transform the error of a failed result. This is especially useful when you want to transform the error into a different error type, or when you want to provide more context to the error: + +```ts +Result.try(() => fs.readFileSync("source.txt", "utf-8")) + .mapCatching(contents => fs.writeFileSync("destination.txt", contents.toUpperCase(), "utf-8")) + .mapError((error) => new IOError("Failed to transform file", { cause: error })); + // Result +``` + #### Side-effects: `onSuccess`, `onFailure` Sometimes you want to perform side-effects without modifying the result itself. This is where `onSuccess` and `onFailure` come in handy. Both methods allow you to run a callback function when the result is successful or when the result represents a failure. The main difference is that `onSuccess` is used for successful results, while `onFailure` is used for failed results. Both methods return the original instance of the result, so you can continue chaining other operations. @@ -648,8 +666,9 @@ const result = Result.all(...tasks.map(createTask)); // Result - [fold(onSuccess, onFailure)](#foldonsuccess-onfailure) - [onFailure(action)](#onfailureaction) - [onSuccess(action)](#onsuccessaction) - - [map(transform)](#maptransform) - - [mapCatching(transform)](#mapcatchingtransform) + - [map(transformFn)](#maptransformfn) + - [mapCatching(transformFn, transformErrorFn?)](#mapcatchingtransformfn-transformerrorfn) + - [mapError(transformFn)](#maperrortransformfn) - [recover(onFailure)](#recoveronfailure) - [recoverCatching(onFailure)](#recovercatchingonfailure) - Static methods @@ -678,7 +697,8 @@ const result = Result.all(...tasks.map(createTask)); // Result - [onFailure(action)](#onfailureaction-1) - [onSuccess(action)](#onsuccessaction-1) - [map(transformFn)](#maptransformfn-1) - - [mapCatching(transformFn)](#mapcatchingtransformfn-1) + - [mapCatching(transformFn, transfornErrorFn?)](#mapcatchingtransformfn-transformerrorfn-1) + - [mapError(transformFn)](#maperrortransformfn-1) - [recover(onFailure)](#recoveronfailure-1) - [recoverCatching(onFailure)](#recovercatchingonfailure-1) @@ -963,7 +983,7 @@ if the `transformFn` function is async. > [!NOTE] > Any exceptions that might be thrown inside the `transformFn` callback are not caught, so it is your responsibility -> to handle these exceptions. Please refer to [`Result.mapCatching()`](#mapcatchingtransformfn) for a version that catches exceptions +> to handle these exceptions. Please refer to [`Result.mapCatching()`](#mapcatchingtransformfn-transformerrorfn) for a version that catches exceptions > and encapsulates them in a failed result. #### Example @@ -1001,7 +1021,7 @@ declare function storeValue(value: number): AsyncResult; const transformed = result.map((value) => storeValue(value)); // AsyncResult ``` -### mapCatching(transformFn) +### mapCatching(transformFn, transformErrorFn?) Like [`Result.map`](#maptransformfn) it transforms the value of a successful result using the `transform` callback. In addition, it catches any exceptions that might be thrown inside the `transform` callback and encapsulates them @@ -1010,9 +1030,31 @@ in a failed result. #### Parameters - `transformFn` callback function to transform the value of the result. The callback can be async as well. +- `transformErrorFn` optional callback function that transforms any caught error inside `transformFn` into a specific error. **returns** * a new [`Result`](#result) instance with the transformed value, or a new [`AsyncResult`](#asyncresult) instance if the transform function is async. +### mapError(transformFn) + +Transforms the error of a failed result using the `transform` callback into a new error. +This can be useful when you want to transform the error into a different error type, or when you want to provide more context to the error. + +#### Parameters + +- `transformFn` callback function to transform the error of the result. + +**returns** a new failed [`Result`](#result) instance with the transformed error. + +#### Example + +transforming the error into a different error type + +```ts +declare const result: Result; + +result.mapError((error) => new ErrorB(error.message)); // Result +``` + ### recover(onFailure) Transforms a failed result using the `onFailure` callback into a successful result. Useful for falling back to @@ -1463,7 +1505,7 @@ The operation will be ignored if the result represents a failure. > [!NOTE] > Any exceptions that might be thrown inside the `transform` callback are not caught, so it is your responsibility -> to handle these exceptions. Please refer to [`AsyncResult.mapCatching`](#mapcatchingtransformfn-1) for a version that catches exceptions +> to handle these exceptions. Please refer to [`AsyncResult.mapCatching`](#mapcatchingtransformfn-transformerrorfn-1) for a version that catches exceptions > and encapsulates them in a failed result. #### Example @@ -1501,7 +1543,7 @@ declare function storeValue(value: number): AsyncResult; const transformed = result.map((value) => storeValue(value)); // AsyncResult ``` -### mapCatching(transformFn) +### mapCatching(transformFn, transformErrorFn?) Like [`AsyncResult.map`](#maptransformfn-1) it transforms the value of a successful result using the `transformFn` callback. In addition, it catches any exceptions that might be thrown inside the `transformFn` callback and encapsulates them @@ -1510,9 +1552,33 @@ in a failed result. #### Parameters - `transformFn` callback function to transform the value of the result. The callback can be async as well. +- `transformErrorFn` optional callback function that transforms any caught error inside `transformFn` into a specific error. **returns** a new [`AsyncResult`](#asyncresult) instance with the transformed value +### mapError(transformFn) + +Transforms the error of a failed result using the `transform` callback into a new error. +This can be useful when you want to transform the error into a different error type, or when you want to provide more context to the error. + +#### Parameters + +- `transformFn` callback function to transform the error of the result. + +**returns** a new failed [`AsyncResult`](#asyncresult) instance with the transformed error. + +#### Example + +transforming the error into a different error type + +```ts +const result = Result.try(() => fetch("https://example.com")) + .mapCatching((response) => response.json() as Promise) + .mapError((error) => new FetchDataError("Failed to fetch data", { cause: error })); + // AsyncResult; +``` + + ### recover(onFailure) Transforms a failed result using the `onFailure` callback into a successful result. Useful for falling back to diff --git a/src/result.test.ts b/src/result.test.ts index a024537..8318bb1 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -1235,6 +1235,94 @@ describe("Result", () => { Result.assertError(nextResult); expect(spy).not.toHaveBeenCalled(); }); + + it("allows you to transform any caught error during the mapping", () => { + const result = Result.ok(2).mapCatching( + (): number => { + throw new Error("boom"); + }, + (err) => { + expectTypeOf(err).toBeUnknown(); + return new ErrorA(); + }, + ); + + expectTypeOf(result).toEqualTypeOf>(); + + Result.assertError(result); + + expect(result.error).toBeInstanceOf(ErrorA); + }); + + it("throws when an exception is thrown while transforming the error", () => { + const fn = () => + Result.ok(2).mapCatching( + (): number => { + throw new CustomError(); + }, + () => { + throw new Error("boom"); + }, + ); + + expect(fn).to.throw(/boom/); + }); + + it("allows you to transform any caught error during async mapping", async () => { + const result = await ( + Result.ok(2) as Result + ).mapCatching( + async (): Promise => { + throw new Error("boom"); + }, + (err) => { + expectTypeOf(err).toBeUnknown(); + return new ErrorB(); + }, + ); + + expectTypeOf(result).toEqualTypeOf>(); + + Result.assertError(result); + + expect(result.error).toBeInstanceOf(ErrorB); + }); + }); + + describe("mapError", () => { + it("lets you transform the error of a failed result into a new error", () => { + const result = Result.error(new ErrorA()) as Result; + + const nextResult = result.mapError((error) => { + expectTypeOf(error).toEqualTypeOf(); + return new ErrorB(); + }); + + expectTypeOf(nextResult).toEqualTypeOf>(); + + Result.assertError(nextResult); + + expect(nextResult.error).toBeInstanceOf(ErrorB); + }); + + it("throws when an exception is thrown while transforming the error", () => { + const fn = () => + Result.error(new ErrorA()).mapError(() => { + throw new Error("boom"); + }); + + expect(fn).to.throw(/boom/); + }); + + it("ignores the operation when the result is ok", () => { + const result = Result.ok(2); + + const spy = vi.fn(); + const nextResult = result.mapError(spy); + + Result.assertOk(nextResult); + expect(spy).not.toHaveBeenCalled(); + }); }); describe("recover", () => { @@ -1980,6 +2068,101 @@ describe("AsyncResult", () => { Result.assertError(result); expect(result.error).toBeInstanceOf(CustomError); }); + + it("allows you to transform any caught error during the mapping", async () => { + const asyncResult = AsyncResult.ok(2).mapCatching( + (): number => { + throw new Error("boom"); + }, + (err) => { + expectTypeOf(err).toBeUnknown(); + return new ErrorA(); + }, + ); + + expectTypeOf(asyncResult).toEqualTypeOf>(); + + const result = await asyncResult; + + Result.assertError(result); + + expect(result.error).toBeInstanceOf(ErrorA); + }); + + it("throws when an exception is thrown while transforming the error", async () => { + const fn = () => + AsyncResult.ok(2).mapCatching( + (): number => { + throw new CustomError(); + }, + () => { + throw new Error("boom"); + }, + ); + + await expect(fn).rejects.toThrow(/boom/); + }); + + it("allows you to transform any caught error during async mapping", async () => { + const result = await ( + AsyncResult.ok(2) as AsyncResult + ).mapCatching( + async (): Promise => { + throw new Error("boom"); + }, + (err) => { + expectTypeOf(err).toBeUnknown(); + return new ErrorB(); + }, + ); + + expectTypeOf(result).toEqualTypeOf>(); + + Result.assertError(result); + + expect(result.error).toBeInstanceOf(ErrorB); + }); + }); + + describe("mapError", () => { + it("lets you transform the error of a failed result into a new error", async () => { + const result = AsyncResult.error(new ErrorA()) as AsyncResult< + number, + ErrorA + >; + + const nextResult = result.mapError((error) => { + expectTypeOf(error).toEqualTypeOf(); + return new ErrorB(); + }); + + expectTypeOf(nextResult).toEqualTypeOf>(); + + const resolvedNextResult = await nextResult; + + Result.assertError(resolvedNextResult); + + expect(resolvedNextResult.error).toBeInstanceOf(ErrorB); + }); + + it("throws when an exception is thrown while transforming the error", async () => { + const fn = () => + AsyncResult.error(new ErrorA()).mapError(() => { + throw new Error("boom"); + }); + + await expect(fn).rejects.toThrow(/boom/); + }); + + it("ignores the operation when the result is ok", async () => { + const result = AsyncResult.ok(2); + + const spy = vi.fn(); + const nextResult = await result.mapError(spy); + + Result.assertOk(nextResult); + expect(spy).not.toHaveBeenCalled(); + }); }); describe("recover", () => { diff --git a/src/result.ts b/src/result.ts index d39d74f..2d6fea9 100644 --- a/src/result.ts +++ b/src/result.ts @@ -352,25 +352,64 @@ export class AsyncResult extends Promise> { } /** - * 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 + * Like {@linkcode AsyncResult.map} it transforms the value of a successful result using the {@link transformValue} callback. + * In addition, it catches any exceptions that might be thrown inside the {@link transformValue} callback and encapsulates them * in a failed result. * - * @param transform callback function to transform the value of the result. The callback can be async as well. + * @param transformValue callback function to transform the value of the result. The callback can be async as well. + * @param transformError callback function to transform any potential caught error while transforming the value. * @returns a new {@linkcode AsyncResult} instance with the transformed value */ - mapCatching(transform: (value: Value) => ReturnType) { - return new AsyncResult((resolve) => { - this.map(transform) + mapCatching( + transformValue: (value: Value) => ReturnType, + transformError?: (error: unknown) => ErrorType, + ) { + return new AsyncResult((resolve, reject) => { + this.map(transformValue) .then((result: AnyResult) => resolve(result)) - .catch((error: unknown) => resolve(Result.error(error))); + .catch((error: unknown) => { + try { + resolve( + Result.error(transformError ? transformError(error) : error), + ); + } catch (err) { + reject(err); + } + }); }) as ReturnType extends Promise ? PromiseValue extends Result - ? AsyncResult - : AsyncResult + ? AsyncResult + : AsyncResult : ReturnType extends Result - ? AsyncResult - : AsyncResult; + ? AsyncResult + : AsyncResult; + } + + /** + * Transforms the encapsulated error of a failed result using the {@link transform} callback into a new error. + * This can be useful for instance to capture similar or related errors and treat them as a single higher-level error type + * @param transform callback function to transform the error of the result. + * @returns new {@linkcode AsyncResult} instance with the transformed error. + * + * @example + * transforming the error of a result + * ```ts + * const result = Result.try(() => fetch("https://example.com")) + * .mapCatching((response) => response.json() as Promise) + * .mapError((error) => new FetchDataError("Failed to fetch data", { cause: error })); + * // AsyncResult; + * ``` + */ + mapError(transform: (error: Err) => NewError) { + return new AsyncResult((resolve, reject) => + this.then(async (result) => { + try { + resolve(result.mapError(transform)); + } catch (error) { + reject(error); + } + }), + ); } /** @@ -923,24 +962,57 @@ export class 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 + * Like {@linkcode Result.map} it transforms the value of a successful result using the {@link transformValue} callback. + * In addition, it catches any exceptions that might be thrown inside the {@link transformValue} callback and encapsulates them * in a failed result. * - * @param transform callback function to transform the value of the result. The callback can be async as well. + * @param transformValue callback function to transform the value of the result. The callback can be async as well. + * @param transformError callback function to transform any potential caught error while transforming the value. * @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( + transformValue: (value: Value) => ReturnType, + transformError?: (err: unknown) => ErrorType, + ) { return ( - this.success ? Result.try(() => transform(this._value)) : this + this.success + ? Result.try( + () => transformValue(this._value), + transformError as AnyFunction, + ) + : this ) as ReturnType extends Promise ? PromiseValue extends Result - ? AsyncResult - : AsyncResult + ? AsyncResult + : AsyncResult : ReturnType extends Result - ? Result - : Result; + ? Result + : Result; + } + + /** + * Transforms the encapsulated error of a failed result using the {@link transform} callback into a new error. + * This can be useful for instance to capture similar or related errors and treat them as a single higher-level error type + * @param transform callback function to transform the error of the result. + * @returns new {@linkcode Result} instance with the transformed error. + * + * @example + * transforming the error of a result + * ```ts + * declare const result: Result; + * + * result.mapError((error) => new ErrorB(error.message)); // Result + * ``` + */ + mapError( + transform: (error: Err) => NewError, + ): Result { + if (this.success) { + return this as Result; + } + + return Result.error(transform(this._error)); } /**