From 6b39bd987fb9ac78ab3fc1beb45cda8b85007b81 Mon Sep 17 00:00:00 2001 From: Mackenzie Salisbury Date: Tue, 1 Oct 2024 16:23:41 +0300 Subject: [PATCH 1/3] feat: Add andFinally functionality and tests --- src/result-async.ts | 17 ++++++++ src/result.ts | 30 ++++++++++++++ tests/index.test.ts | 98 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/src/result-async.ts b/src/result-async.ts index 01f2f13e..1a3d1865 100644 --- a/src/result-async.ts +++ b/src/result-async.ts @@ -163,6 +163,23 @@ export class ResultAsync implements PromiseLike> { ) } + andFinally(f: () => ResultAsync | Result): ResultAsync { + return new ResultAsync( + this._promise.then( + async (res) => { + const cleanupResult = await f() + return res.andFinally(() => cleanupResult) + }, + async (err) => { + // this should be unreachable for well-behaved ResultAsync instances, but let's run the + // cleanup and propagate the erroneous rejection anyway + await f() + throw err + }, + ), + ) + } + orElse>( f: (e: E) => R, ): ResultAsync | T, InferErrTypes> diff --git a/src/result.ts b/src/result.ts index f2b84989..28bd9480 100644 --- a/src/result.ts +++ b/src/result.ts @@ -203,6 +203,18 @@ interface IResult { andThrough>(f: (t: T) => R): Result | E> andThrough(f: (t: T) => Result): Result + /** + * Similar to a `finally` block in throw-based code. Runs the given callback + * regardless of whether the result is an `Ok` or an `Err`. + * + * This is useful for cleanup operations that should always be run regardless of an error. + * + * An `Ok` returned from the callback will always be ignored, while any `Err` + * returned from the callback will be returned and replace the original `Err` + * if there was one. . + */ + andFinally(f: () => Result): Result + /** * Takes an `Err` value and maps it to a `Result`. * @@ -330,6 +342,15 @@ export class Ok implements IResult { return ok(this.value) } + andFinally(cleanup: () => Result): Result { + const cleanupResult = cleanup() + if (cleanupResult.isErr()) { + return err(cleanupResult.error) + } else { + return this + } + } + orElse>( _f: (e: E) => R, ): Result | T, InferErrTypes> @@ -420,6 +441,15 @@ export class Err implements IResult { return err(this.error) } + andFinally(cleanup: () => Result): Result { + const cleanupResult = cleanup() + if (cleanupResult.isErr()) { + return err(cleanupResult.error) + } else { + return this + } + } + orElse>( f: (e: E) => R, ): Result | T, InferErrTypes> diff --git a/tests/index.test.ts b/tests/index.test.ts index 41f1b75d..a0f902c9 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -155,6 +155,25 @@ describe('Result.Ok', () => { }) }) + describe('andFinally', () => { + it('calls the callback and returns the original ok value', () => { + const okVal = ok("original ok value"); + const andFinallyFn = jest.fn(() => ok("finally ok value")); + const finalResult = okVal.andFinally(andFinallyFn); + + expect(andFinallyFn).toHaveBeenCalledTimes(1); + expect(finalResult._unsafeUnwrap()).toBe("original ok value"); + }) + it('calls the callback and returns the error from the callback', () => { + const okVal = ok("original ok value"); + const andFinallyFn = jest.fn(() => err("error from callback")); + const finalResult = okVal.andFinally(andFinallyFn); + + expect(andFinallyFn).toHaveBeenCalledTimes(1); + expect(finalResult._unsafeUnwrapErr()).toBe("error from callback"); + }) + }) + describe('asyncAndThrough', () => { it('Calls the passed function but returns an original ok as Async', async () => { const okVal = ok(12) @@ -356,6 +375,25 @@ describe('Result.Err', () => { expect(errVal._unsafeUnwrapErr()).toEqual('Yolo') }) + describe('andFinally', () => { + it('calls the callback and returns the original error', () => { + const okVal = err("original error"); + const andFinallyFn = jest.fn(() => ok("finally ok value")); + const finalResult = okVal.andFinally(andFinallyFn); + + expect(andFinallyFn).toHaveBeenCalledTimes(1); + expect(finalResult._unsafeUnwrapErr()).toBe("original error"); + }) + it('calls the callback and returns the error from the callback', () => { + const okVal = err("original error"); + const andFinallyFn = jest.fn(() => err("error from callback")); + const finalResult = okVal.andFinally(andFinallyFn); + + expect(andFinallyFn).toHaveBeenCalledTimes(1); + expect(finalResult._unsafeUnwrapErr()).toBe("error from callback"); + }) + }) + it('Skips over asyncAndThrough but returns ResultAsync instead', async () => { const errVal = err('Yolo') @@ -1064,6 +1102,66 @@ describe('ResultAsync', () => { }) }) + describe('andFinally', () => { + it('runs the callback when the result is Ok and passes through the value', async () => { + const okVal = okAsync(42) + const finallyFn = jest.fn(() => { + return ok('this value is unused') + }) + const finalResult = okVal.andFinally(finallyFn) + const awaitedResult = await finalResult + + expect(finallyFn).toHaveBeenCalledTimes(1) + expect(finalResult).toBeInstanceOf(ResultAsync) + expect(awaitedResult._unsafeUnwrap()).toBe(42) + }) + it('runs the callback when the result is Ok and returns an error from the callback', async () => { + const okVal = okAsync(42) + const finallyFn = jest.fn(() => { + return err('error from andFinally') + }) + const finalResult = okVal.andFinally(finallyFn) + const awaitedResult = await finalResult + + expect(finalResult).toBeInstanceOf(ResultAsync) + expect(finallyFn).toHaveBeenCalledTimes(1) + expect(awaitedResult._unsafeUnwrapErr()).toBe('error from andFinally') + }) + it('runs the callback when the result is Err and passes through the error', async () => { + const errVal = errAsync('original error'); + const finallyFn = jest.fn(() => { + return ok('this value is unused'); + }) + const finalResult = errVal.andFinally(finallyFn); + const awaitedResult = await finalResult; + + expect(finalResult).toBeInstanceOf(ResultAsync); + expect(finallyFn).toHaveBeenCalledTimes(1); + expect(awaitedResult._unsafeUnwrapErr()).toBe('original error'); + }) + it('runs the callback when the result is Err and returns an error from the callback', async ()=>{ + const errVal = errAsync('original error'); + const finallyFn = jest.fn(() => { + return err('error from finally'); + }) + const finalResult = errVal.andFinally(finallyFn); + const awaitedResult = await finalResult; + + expect(finalResult).toBeInstanceOf(ResultAsync); + expect(finallyFn).toHaveBeenCalledTimes(1); + expect(awaitedResult._unsafeUnwrapErr()).toBe('error from finally'); + }) + it('runs the callback when a misbehaving ResultAsync rejects, and propagates the rejection', async () => { + const misbehavingResult = new ResultAsync(Promise.reject('oops')); + const finallyFn = jest.fn(() => { + return ok('this value is unused'); + }) + const finalResult = misbehavingResult.andFinally(finallyFn); + await expect(finalResult).rejects.toBe('oops'); + expect(finallyFn).toHaveBeenCalledTimes(1); + }) + }) + describe('orElse', () => { it('Skips orElse on an Ok value', async () => { const okVal = okAsync(12) From 829a2a860d2e34fd9227651a542d8800ebe38687 Mon Sep 17 00:00:00 2001 From: Mackenzie Salisbury Date: Tue, 1 Oct 2024 17:42:06 +0300 Subject: [PATCH 2/3] feat: Use more specific return types for Ok/Err.andFinally --- src/result.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/result.ts b/src/result.ts index 28bd9480..e6c24430 100644 --- a/src/result.ts +++ b/src/result.ts @@ -342,12 +342,12 @@ export class Ok implements IResult { return ok(this.value) } - andFinally(cleanup: () => Result): Result { + andFinally(cleanup: () => Result): Ok | Err { const cleanupResult = cleanup() if (cleanupResult.isErr()) { return err(cleanupResult.error) } else { - return this + return ok(this.value) } } @@ -441,12 +441,12 @@ export class Err implements IResult { return err(this.error) } - andFinally(cleanup: () => Result): Result { + andFinally(cleanup: () => Result): Err { const cleanupResult = cleanup() if (cleanupResult.isErr()) { return err(cleanupResult.error) } else { - return this + return err(this.error) } } From 69bb69745627a34684927bec13f19cbd702f56b2 Mon Sep 17 00:00:00 2001 From: Mackenzie Salisbury Date: Tue, 1 Oct 2024 17:46:22 +0300 Subject: [PATCH 3/3] Add changeset for `andFinally` feature --- .changeset/thirty-berries-complain.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thirty-berries-complain.md diff --git a/.changeset/thirty-berries-complain.md b/.changeset/thirty-berries-complain.md new file mode 100644 index 00000000..b190ff92 --- /dev/null +++ b/.changeset/thirty-berries-complain.md @@ -0,0 +1,5 @@ +--- +'neverthrow': minor +--- + +Add `andFinally` to allow for synchronous or asynchronous cleanup code that will always be called, and only propagates errors.