diff --git a/.changeset/thin-wolves-notice.md b/.changeset/thin-wolves-notice.md new file mode 100644 index 00000000..2596b177 --- /dev/null +++ b/.changeset/thin-wolves-notice.md @@ -0,0 +1,5 @@ +--- +'neverthrow': minor +--- + +Add a function that combines multiple results and returns an object. diff --git a/README.md b/README.md index 7ab7a971..f49a2dee 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ For asynchronous tasks, `neverthrow` offers a `ResultAsync` class which wraps a - [`Result.fromThrowable` (static class method)](#resultfromthrowable-static-class-method) - [`Result.combine` (static class method)](#resultcombine-static-class-method) - [`Result.combineWithAllErrors` (static class method)](#resultcombinewithallerrors-static-class-method) + - [`Result.struct` (static class method)](#resultstruct-static-class-method) - [`Result.safeUnwrap()`](#resultsafeunwrap) + [Asynchronous API (`ResultAsync`)](#asynchronous-api-resultasync) - [`okAsync`](#okasync) @@ -58,6 +59,7 @@ For asynchronous tasks, `neverthrow` offers a `ResultAsync` class which wraps a - [`ResultAsync.andThrough` (method)](#resultasyncandthrough-method) - [`ResultAsync.combine` (static class method)](#resultasynccombine-static-class-method) - [`ResultAsync.combineWithAllErrors` (static class method)](#resultasynccombinewithallerrors-static-class-method) + - [`ResultAsync.struct` (static class method)](#resultasyncstruct-static-class-method) - [`ResultAsync.safeUnwrap()`](#resultasyncsafeunwrap) + [Utilities](#utilities) - [`fromThrowable`](#fromthrowable) @@ -800,6 +802,59 @@ const result = Result.combineWithAllErrors(resultList) [⬆️ Back to top](#toc) +--- + +#### `Result.struct` (static class method) + +> Although Result is not an actual JS class, the way that `struct` has been implemented requires that you call `struct` as though it were a static method on `Result`. See examples below. + +Combine objects of `Result`s. + +**`struct` works on both heterogeneous and homogeneous objects**. This means that you can have objects that contain different kinds of `Result`s and still be able to combine them. Note that you cannot combine objects that contain both `Result`s **and** `ResultAsync`s. + +The `struct` function takes an object of results and returns a single result. If all the results in the object are `Ok`, then the return value will be a `Ok` containing an object of all the individual `Ok` values. + +If multiple results in the object are `Err` then the `struct` function returns an `Err` containing an array of all the error values. + +Example: +```typescript +const resultObject: { + a: Result + b: Result +} = { + a: ok(1), + b: ok(2), +} + +const combinedList: Result<{ + a: number + b: number +}, never> = Result.struct(resultObject) +``` + +Example of error: +```typescript +const resultObject: { + a: Result + b: Result + c: Result +} = { + a: ok(1), + b: err(2), + c: err('3'), +} + +const combinedList: Result<{ + a: number + b: unknown + c: unknown +}, (number | number)[]> = Result.struct(resultObject) +``` + +[⬆️ Back to top](#toc) + +--- + #### `Result.safeUnwrap()` **Deprecated**. You don't need to use this method anymore. @@ -1410,6 +1465,57 @@ const result = ResultAsync.combineWithAllErrors(resultList) // result is Err(['boooom!', 'ahhhhh!']) ``` +--- + +#### `ResultAsync.struct` (static class method) + +Combine objects of `ResultAsyncs`s. + +**`struct` works on both heterogeneous and homogeneous objects**. This means that you can have objects that contain different kinds of `Result`s and still be able to combine them. Note that unlike `Result.struct`, you can combine objects containing both `Result`s and `ResultAsync`s. + +The `struct` function takes an object of results and returns a single result. If all the results in the object are `Ok`, then the return value will be a `Ok` containing an object of all the individual `Ok` values. + +If multiple results in the object are `Err` then the `struct` function returns an `Err` containing an array of all the error values. + +Example: +```typescript +const resultObject: { + a: ResultAsync + b: ResultAsync +} = { + a: okAsync(1), + b: okAsync(2), +} + +const combinedList: ResultAsync<{ + a: number + b: number +}, never> = ResultAsync.struct(resultObject) +``` + +Example of error: +```typescript +const resultObject: { + a: ResultAsync + b: ResultAsync + c: ResultAsync +} = { + a: okAsync(1), + b: errAsync(2), + c: errAsync('3'), +} + +const combinedList: ResultAsync<{ + a: number + b: unknown + c: unknown +}, (number | number)[]> = ResultAsync.struct(resultObject) +``` + +[⬆️ Back to top](#toc) + +--- + #### `ResultAsync.safeUnwrap()` **Deprecated**. You don't need to use this method anymore. diff --git a/src/_internals/utils.ts b/src/_internals/utils.ts index 04196144..6d0ec6c9 100644 --- a/src/_internals/utils.ts +++ b/src/_internals/utils.ts @@ -21,12 +21,17 @@ export type ExtractErrAsyncTypes ? E : never } +export type MaybeResultAsync = ResultAsync | Result + export type InferOkTypes = R extends Result ? T : never export type InferErrTypes = R extends Result ? E : never export type InferAsyncOkTypes = R extends ResultAsync ? T : never export type InferAsyncErrTypes = R extends ResultAsync ? E : never +export type InferMaybeAsyncOkTypes = R extends MaybeResultAsync ? T : never +export type InferMaybeAsyncErrTypes = R extends MaybeResultAsync ? E : never + /** * Short circuits on the FIRST Err value that we find */ diff --git a/src/result-async.ts b/src/result-async.ts index 9c349c54..9ed59492 100644 --- a/src/result-async.ts +++ b/src/result-async.ts @@ -16,7 +16,10 @@ import { InferAsyncErrTypes, InferAsyncOkTypes, InferErrTypes, + InferMaybeAsyncErrTypes, + InferMaybeAsyncOkTypes, InferOkTypes, + MaybeResultAsync, } from './_internals/utils' export class ResultAsync implements PromiseLike> { @@ -86,6 +89,35 @@ export class ResultAsync implements PromiseLike> { ) as CombineResultsWithAllErrorsArrayAsync } + static struct>>( + record: T, + ): StructResultAsync { + const results = Object.entries(record).reduce< + Array> + >((previous, [key, value]) => { + previous.push(value.map((result) => [key, result])) + return previous + }, []) + + return ResultAsync.fromSafePromise(Promise.all(results)).andThen((results) => { + const errors = results.filter((result) => result.isErr()) + if (0 < errors.length) { + return errAsync( + errors.map((error) => (error as Err>).error), + ) + } + + const successes = results as Ok<[string, unknown], unknown>[] + return okAsync( + successes.reduce>((previous, result) => { + const [key, value] = result.value + previous[key] = value + return previous + }, {}) as { [Key in keyof T]: InferMaybeAsyncOkTypes }, + ) + }) + } + map(f: (t: T) => A | Promise): ResultAsync { return new ResultAsync( this._promise.then(async (res: Result) => { @@ -259,6 +291,14 @@ export type CombineResultsWithAllErrorsArrayAsync< ? TraverseWithAllErrorsAsync> : ResultAsync, ExtractErrAsyncTypes[number][]> +export type StructResultAsync< + E, + T extends Record> +> = ResultAsync< + { [Key in keyof T]: InferMaybeAsyncOkTypes }, + Array> +> + // Unwraps the inner `Result` from a `ResultAsync` for all elements. type UnwrapAsync = IsLiteralArray extends 1 ? Writable extends [infer H, ...infer Rest] diff --git a/src/result.ts b/src/result.ts index 3f6f5a94..8914532a 100644 --- a/src/result.ts +++ b/src/result.ts @@ -57,6 +57,23 @@ export namespace Result { ): CombineResultsWithAllErrorsArray { return combineResultListWithAllErrors(resultList) as CombineResultsWithAllErrorsArray } + + export function struct>>( + record: T, + ): StructResult { + const errors = Object.values(record).filter((result) => result.isErr()) + if (0 < errors.length) { + return err(errors.map((error) => (error as Err>).error)) + } + + const successes = Object.entries(record) as [string, Ok][] + return ok( + successes.reduce>((previous, [key, result]) => { + previous[key] = result.value + return previous + }, {}) as { [Key in keyof T]: InferOkTypes }, + ) + } } export type Result = Ok | Err @@ -697,4 +714,9 @@ export type CombineResultsWithAllErrorsArray< ? TraverseWithAllErrors : Result, ExtractErrTypes[number][]> +export type StructResult>> = Result< + { [Key in keyof T]: InferOkTypes }, + Array> +> + //#endregion diff --git a/tests/index.test.ts b/tests/index.test.ts index e5d94852..52230df4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -738,6 +738,104 @@ describe('Utils', () => { }) }) }) + describe('`Result.struct`', () => { + it('returns Ok with all values when all results are Ok', () => { + const input = { + a: ok(1), + b: ok('test'), + c: ok(true), + } + + const result = Result.struct(input) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual({ a: 1, b: 'test', c: true }) + }) + + it('returns Err with a single error when one result is Err', () => { + const input = { + a: ok(1), + b: err('error1'), + c: ok(true), + } + + const result = Result.struct(input) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toEqual(['error1']) + }) + + it('returns Err with all errors when some results are Err', () => { + const input = { + a: ok(1), + b: err('error1'), + c: err('error2'), + } + + const result = Result.struct(input) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toEqual(['error1', 'error2']) + }) + + it('returns Ok with an empty object when input is an empty object', () => { + const input = {} + + const result = Result.struct(input) + + expect(result.isOk()).toBe(true) + expect(result.unwrapOr({})).toEqual({}) + }) + }) + describe('`ResultAsync.struct`', () => { + it('returns Ok with all values when all results are Ok', async () => { + const input = { + a: okAsync(1), + b: okAsync('test'), + c: okAsync(true), + } + + const result = await ResultAsync.struct(input) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual({ a: 1, b: 'test', c: true }) + }) + + it('returns Err with a single error when one result is Err', async () => { + const input = { + a: okAsync(1), + b: errAsync('error1'), + c: okAsync(true), + } + + const result = await ResultAsync.struct(input) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toEqual(['error1']) + }) + + it('returns Err with all errors when some results are Err', async () => { + const input = { + a: okAsync(1), + b: errAsync('error1'), + c: errAsync('error2'), + } + + const result = await ResultAsync.struct(input) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toEqual(['error1', 'error2']) + }) + + it('returns Ok with an empty object when input is an empty object', async () => { + const input = {} + + const result = await ResultAsync.struct(input) + + expect(result.isOk()).toBe(true) + expect(result.unwrapOr({})).toEqual({}) + }) + }) }) describe('ResultAsync', () => {