Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-berries-complain.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions src/result-async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,23 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
)
}

andFinally<F>(f: () => ResultAsync<unknown, F> | Result<unknown, F>): ResultAsync<T, E | F> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowing for either sync or async Results to be returned by the callback. I couldn't foresee any problems with this and it makes the method more flexible.

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
Comment on lines +190 to +193
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the PR description for more details about why this was added. Open to hearing concerns about this.

Copy link

@hosuaby hosuaby May 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @macksal

Nice PR. I am facing the same issue so I came here. It would be great if maintainers gave some attention to this PR.

The only problem I see, is that the finalization function f can return an error ResultAsync object, and in that case, this error will be simply swallowed. And this goes against the philosophy of explicitness that neverthrow follows.

Unfortunately, there is no way around but to create some sort of combined error that will return both original and finalization errors.

Here is how I implemented it in my code:

import { ResultAsync } from 'neverthrow';
import { Throwable } from './throwable';

export class CombinedError<PE, FE> extends Throwable {
  public readonly _tag = 'CombinedError';

  constructor(
    public readonly programError: PE,
    public readonly finalizationError: FE,
  ) {
    super('Both program and finalization have failed');
  }
}

export function andFinally<R, PE, FE>(
  program: ResultAsync<R, PE>,
  finalization: () => ResultAsync<void, FE>,
): ResultAsync<R, PE | FE | CombinedError<PE, FE>> {
  return ResultAsync.fromSafePromise(
    new Promise((resolve, reject) => {
      program.match(
        async (result) => {
          await finalization().match(
            () => resolve(result),
            (finalizationError: FE) => reject(finalizationError),
          );
        },
        async (error) => {
          await finalization().match(
            () => reject(error),
            (finalizationError: FE) => reject(new CombinedError(error, finalizationError)),
          );
        },
      );
    }),
  );
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hosuaby I did this to mirror the precedent of throwing inside finally blocks - most languages do not special case this and simply "overwrite" the previously thrown error if there was one. By extension if our andFinally cleanup fails, we will return that error and ignore the error from the existing result (if there is one).

In my eyes the finally use case is a tool for when you want to decouple some cleanup behaviour from the result of everything before it ("just make sure we run this regardless of what happens beforehand")

Therefore if you find yourself wanting to reintroduce that coupling by inspecting the combined error types, those behaviours are probably more related than you thought. You can easily solve this by making your steps+cleanup in the happy/sad paths more explicit. Whatever we do in the library here would be right for some consumers and wrong for others IMO, so let's go with what is most simple

},
),
)
}

orElse<R extends Result<unknown, unknown>>(
f: (e: E) => R,
): ResultAsync<InferOkTypes<R> | T, InferErrTypes<R>>
Expand Down
30 changes: 30 additions & 0 deletions src/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ interface IResult<T, E> {
andThrough<R extends Result<unknown, unknown>>(f: (t: T) => R): Result<T, InferErrTypes<R> | E>
andThrough<F>(f: (t: T) => Result<unknown, F>): Result<T, E | F>

/**
* 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>(f: () => Result<unknown, F>): Result<T, E | F>

/**
* Takes an `Err` value and maps it to a `Result<T, SomeNewType>`.
*
Expand Down Expand Up @@ -358,6 +370,15 @@ export class Ok<T, E> implements IResult<T, E> {
return ok<T, E>(this.value)
}

andFinally<F>(cleanup: () => Result<unknown, F>): Ok<T, never> | Err<never, F> {
const cleanupResult = cleanup()
if (cleanupResult.isErr()) {
return err(cleanupResult.error)
} else {
return ok(this.value)
}
}

orElse<R extends Result<unknown, unknown>>(
_f: (e: E) => R,
): Result<InferOkTypes<R> | T, InferErrTypes<R>>
Expand Down Expand Up @@ -462,6 +483,15 @@ export class Err<T, E> implements IResult<T, E> {
return err(this.error)
}

andFinally<F>(cleanup: () => Result<unknown, F>): Err<never, E | F> {
const cleanupResult = cleanup()
if (cleanupResult.isErr()) {
return err(cleanupResult.error)
} else {
return err(this.error)
}
}

orElse<R extends Result<unknown, unknown>>(
f: (e: E) => R,
): Result<InferOkTypes<R> | T, InferErrTypes<R>>
Expand Down
100 changes: 99 additions & 1 deletion tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
ResultAsync,
} from '../src'

import { vitest, describe, expect, it } from 'vitest'
import { describe, expect, it, vitest } from 'vitest'

describe('Result.Ok', () => {
it('Creates an Ok value', () => {
Expand Down Expand Up @@ -184,6 +184,25 @@
})
})

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"));

Check failure on line 190 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > Result.Ok > andFinally > calls the callback and returns the original ok value

ReferenceError: jest is not defined ❯ tests/index.test.ts:190:28
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"));

Check failure on line 198 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > Result.Ok > andFinally > calls the callback and returns the error from the callback

ReferenceError: jest is not defined ❯ tests/index.test.ts:198:28
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)
Expand Down Expand Up @@ -385,6 +404,25 @@
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"));

Check failure on line 410 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > Result.Err > andFinally > calls the callback and returns the original error

ReferenceError: jest is not defined ❯ tests/index.test.ts:410:28
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"));

Check failure on line 418 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > Result.Err > andFinally > calls the callback and returns the error from the callback

ReferenceError: jest is not defined ❯ tests/index.test.ts:418:28
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')

Expand Down Expand Up @@ -1113,6 +1151,66 @@
expect(teed._unsafeUnwrapErr()).toStrictEqual(12)
})
})

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(() => {

Check failure on line 1158 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > ResultAsync > andFinally > runs the callback when the result is Ok and passes through the value

ReferenceError: jest is not defined ❯ tests/index.test.ts:1158:25
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(() => {

Check failure on line 1170 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > ResultAsync > andFinally > runs the callback when the result is Ok and returns an error from the callback

ReferenceError: jest is not defined ❯ tests/index.test.ts:1170:25
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(() => {

Check failure on line 1182 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > ResultAsync > andFinally > runs the callback when the result is Err and passes through the error

ReferenceError: jest is not defined ❯ tests/index.test.ts:1182:25
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(() => {

Check failure on line 1194 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > ResultAsync > andFinally > runs the callback when the result is Err and returns an error from the callback

ReferenceError: jest is not defined ❯ tests/index.test.ts:1194:25
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(() => {

Check failure on line 1206 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / test (18)

tests/index.test.ts > ResultAsync > andFinally > runs the callback when a misbehaving ResultAsync rejects, and propagates the rejection

ReferenceError: jest is not defined ❯ tests/index.test.ts:1206:25
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 () => {
Expand Down
Loading