Skip to content

Commit 595c76a

Browse files
committed
feat: Add a function that combines multiple results and returns an object
1 parent 493e9e2 commit 595c76a

File tree

6 files changed

+276
-0
lines changed

6 files changed

+276
-0
lines changed

.changeset/thin-wolves-notice.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'neverthrow': minor
3+
---
4+
5+
Add a function that combines multiple results and returns an object.

README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ For asynchronous tasks, `neverthrow` offers a `ResultAsync` class which wraps a
4141
- [`Result.fromThrowable` (static class method)](#resultfromthrowable-static-class-method)
4242
- [`Result.combine` (static class method)](#resultcombine-static-class-method)
4343
- [`Result.combineWithAllErrors` (static class method)](#resultcombinewithallerrors-static-class-method)
44+
- [`Result.struct` (static class method)](#resultstruct-static-class-method)
4445
- [`Result.safeUnwrap()`](#resultsafeunwrap)
4546
+ [Asynchronous API (`ResultAsync`)](#asynchronous-api-resultasync)
4647
- [`okAsync`](#okasync)
@@ -58,6 +59,7 @@ For asynchronous tasks, `neverthrow` offers a `ResultAsync` class which wraps a
5859
- [`ResultAsync.andThrough` (method)](#resultasyncandthrough-method)
5960
- [`ResultAsync.combine` (static class method)](#resultasynccombine-static-class-method)
6061
- [`ResultAsync.combineWithAllErrors` (static class method)](#resultasynccombinewithallerrors-static-class-method)
62+
- [`ResultAsync.struct` (static class method)](#resultasyncstruct-static-class-method)
6163
- [`ResultAsync.safeUnwrap()`](#resultasyncsafeunwrap)
6264
+ [Utilities](#utilities)
6365
- [`fromThrowable`](#fromthrowable)
@@ -800,6 +802,59 @@ const result = Result.combineWithAllErrors(resultList)
800802

801803
[⬆️ Back to top](#toc)
802804

805+
---
806+
807+
#### `Result.struct` (static class method)
808+
809+
> 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.
810+
811+
Combine objects of `Result`s.
812+
813+
**`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.
814+
815+
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.
816+
817+
If multiple results in the object are `Err` then the `struct` function returns an `Err` containing an array of all the error values.
818+
819+
Example:
820+
```typescript
821+
const resultObject: {
822+
a: Result<number, never>
823+
b: Result<number, never>
824+
} = {
825+
a: ok(1),
826+
b: ok(2),
827+
}
828+
829+
const combinedList: Result<{
830+
a: number
831+
b: number
832+
}, never> = Result.struct(resultObject)
833+
```
834+
835+
Example of error:
836+
```typescript
837+
const resultObject: {
838+
a: Result<number, unknown>
839+
b: Result<never, number>
840+
c: Result<never, string>
841+
} = {
842+
a: ok(1),
843+
b: err(2),
844+
c: err('3'),
845+
}
846+
847+
const combinedList: Result<{
848+
a: number
849+
b: unknown
850+
c: unknown
851+
}, (number | number)[]> = Result.struct(resultObject)
852+
```
853+
854+
[⬆️ Back to top](#toc)
855+
856+
---
857+
803858
#### `Result.safeUnwrap()`
804859
805860
**Deprecated**. You don't need to use this method anymore.
@@ -1410,6 +1465,57 @@ const result = ResultAsync.combineWithAllErrors(resultList)
14101465
// result is Err(['boooom!', 'ahhhhh!'])
14111466
```
14121467

1468+
---
1469+
1470+
#### `ResultAsync.struct` (static class method)
1471+
1472+
Combine objects of `ResultAsyncs`s.
1473+
1474+
**`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.
1475+
1476+
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.
1477+
1478+
If multiple results in the object are `Err` then the `struct` function returns an `Err` containing an array of all the error values.
1479+
1480+
Example:
1481+
```typescript
1482+
const resultObject: {
1483+
a: ResultAsync<number, never>
1484+
b: ResultAsync<number, never>
1485+
} = {
1486+
a: okAsync(1),
1487+
b: okAsync(2),
1488+
}
1489+
1490+
const combinedList: ResultAsync<{
1491+
a: number
1492+
b: number
1493+
}, never> = ResultAsync.struct(resultObject)
1494+
```
1495+
1496+
Example of error:
1497+
```typescript
1498+
const resultObject: {
1499+
a: ResultAsync<number, unknown>
1500+
b: ResultAsync<never, number>
1501+
c: ResultAsync<never, string>
1502+
} = {
1503+
a: okAsync(1),
1504+
b: errAsync(2),
1505+
c: errAsync('3'),
1506+
}
1507+
1508+
const combinedList: ResultAsync<{
1509+
a: number
1510+
b: unknown
1511+
c: unknown
1512+
}, (number | number)[]> = ResultAsync.struct(resultObject)
1513+
```
1514+
1515+
[⬆️ Back to top](#toc)
1516+
1517+
---
1518+
14131519
#### `ResultAsync.safeUnwrap()`
14141520
14151521
**Deprecated**. You don't need to use this method anymore.

src/_internals/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@ export type ExtractErrAsyncTypes<T extends readonly ResultAsync<unknown, unknown
2121
[idx in keyof T]: T[idx] extends ResultAsync<unknown, infer E> ? E : never
2222
}
2323

24+
export type MaybeResultAsync<T, E> = ResultAsync<T, E> | Result<T, E>
25+
2426
export type InferOkTypes<R> = R extends Result<infer T, unknown> ? T : never
2527
export type InferErrTypes<R> = R extends Result<unknown, infer E> ? E : never
2628

2729
export type InferAsyncOkTypes<R> = R extends ResultAsync<infer T, unknown> ? T : never
2830
export type InferAsyncErrTypes<R> = R extends ResultAsync<unknown, infer E> ? E : never
2931

32+
export type InferMaybeAsyncOkTypes<R> = R extends MaybeResultAsync<infer T, unknown> ? T : never
33+
export type InferMaybeAsyncErrTypes<R> = R extends MaybeResultAsync<unknown, infer E> ? E : never
34+
3035
/**
3136
* Short circuits on the FIRST Err value that we find
3237
*/

src/result-async.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616
InferAsyncErrTypes,
1717
InferAsyncOkTypes,
1818
InferErrTypes,
19+
InferMaybeAsyncErrTypes,
20+
InferMaybeAsyncOkTypes,
1921
InferOkTypes,
22+
MaybeResultAsync,
2023
} from './_internals/utils'
2124

2225
export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
@@ -86,6 +89,35 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
8689
) as CombineResultsWithAllErrorsArrayAsync<T>
8790
}
8891

92+
static struct<E, T extends Record<string, MaybeResultAsync<unknown, E>>>(
93+
record: T,
94+
): StructResultAsync<E, T> {
95+
const results = Object.entries(record).reduce<
96+
Array<MaybeResultAsync<[string, unknown], unknown>>
97+
>((previous, [key, value]) => {
98+
previous.push(value.map((result) => [key, result]))
99+
return previous
100+
}, [])
101+
102+
return ResultAsync.fromSafePromise(Promise.all(results)).andThen((results) => {
103+
const errors = results.filter((result) => result.isErr())
104+
if (0 < errors.length) {
105+
return errAsync(
106+
errors.map((error) => (error as Err<unknown, InferMaybeAsyncErrTypes<T[keyof T]>>).error),
107+
)
108+
}
109+
110+
const successes = results as Ok<[string, unknown], unknown>[]
111+
return okAsync(
112+
successes.reduce<Record<string, unknown>>((previous, result) => {
113+
const [key, value] = result.value
114+
previous[key] = value
115+
return previous
116+
}, {}) as { [Key in keyof T]: InferMaybeAsyncOkTypes<T[Key]> },
117+
)
118+
})
119+
}
120+
89121
map<A>(f: (t: T) => A | Promise<A>): ResultAsync<A, E> {
90122
return new ResultAsync(
91123
this._promise.then(async (res: Result<T, E>) => {
@@ -259,6 +291,14 @@ export type CombineResultsWithAllErrorsArrayAsync<
259291
? TraverseWithAllErrorsAsync<UnwrapAsync<T>>
260292
: ResultAsync<ExtractOkAsyncTypes<T>, ExtractErrAsyncTypes<T>[number][]>
261293

294+
export type StructResultAsync<
295+
E,
296+
T extends Record<string, MaybeResultAsync<unknown, E>>
297+
> = ResultAsync<
298+
{ [Key in keyof T]: InferMaybeAsyncOkTypes<T[Key]> },
299+
Array<InferMaybeAsyncErrTypes<T[keyof T]>>
300+
>
301+
262302
// Unwraps the inner `Result` from a `ResultAsync` for all elements.
263303
type UnwrapAsync<T> = IsLiteralArray<T> extends 1
264304
? Writable<T> extends [infer H, ...infer Rest]

src/result.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ export namespace Result {
5757
): CombineResultsWithAllErrorsArray<T> {
5858
return combineResultListWithAllErrors(resultList) as CombineResultsWithAllErrorsArray<T>
5959
}
60+
61+
export function struct<E, T extends Record<string, Result<unknown, E>>>(
62+
record: T,
63+
): StructResult<E, T> {
64+
const errors = Object.values(record).filter((result) => result.isErr())
65+
if (0 < errors.length) {
66+
return err(errors.map((error) => (error as Err<unknown, InferErrTypes<T[keyof T]>>).error))
67+
}
68+
69+
const successes = Object.entries(record) as [string, Ok<T[keyof T], unknown>][]
70+
return ok(
71+
successes.reduce<Record<string, unknown>>((previous, [key, result]) => {
72+
previous[key] = result.value
73+
return previous
74+
}, {}) as { [Key in keyof T]: InferOkTypes<T[Key]> },
75+
)
76+
}
6077
}
6178

6279
export type Result<T, E> = Ok<T, E> | Err<T, E>
@@ -697,4 +714,9 @@ export type CombineResultsWithAllErrorsArray<
697714
? TraverseWithAllErrors<T>
698715
: Result<ExtractOkTypes<T>, ExtractErrTypes<T>[number][]>
699716

717+
export type StructResult<E, T extends Record<string, Result<unknown, E>>> = Result<
718+
{ [Key in keyof T]: InferOkTypes<T[Key]> },
719+
Array<InferErrTypes<T[keyof T]>>
720+
>
721+
700722
//#endregion

tests/index.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,104 @@ describe('Utils', () => {
738738
})
739739
})
740740
})
741+
describe('`Result.struct`', () => {
742+
it('returns Ok with all values when all results are Ok', () => {
743+
const input = {
744+
a: ok(1),
745+
b: ok('test'),
746+
c: ok(true),
747+
}
748+
749+
const result = Result.struct(input)
750+
751+
expect(result.isOk()).toBe(true)
752+
expect(result._unsafeUnwrap()).toEqual({ a: 1, b: 'test', c: true })
753+
})
754+
755+
it('returns Err with a single error when one result is Err', () => {
756+
const input = {
757+
a: ok(1),
758+
b: err('error1'),
759+
c: ok(true),
760+
}
761+
762+
const result = Result.struct(input)
763+
764+
expect(result.isErr()).toBe(true)
765+
expect(result._unsafeUnwrapErr()).toEqual(['error1'])
766+
})
767+
768+
it('returns Err with all errors when some results are Err', () => {
769+
const input = {
770+
a: ok(1),
771+
b: err('error1'),
772+
c: err('error2'),
773+
}
774+
775+
const result = Result.struct(input)
776+
777+
expect(result.isErr()).toBe(true)
778+
expect(result._unsafeUnwrapErr()).toEqual(['error1', 'error2'])
779+
})
780+
781+
it('returns Ok with an empty object when input is an empty object', () => {
782+
const input = {}
783+
784+
const result = Result.struct(input)
785+
786+
expect(result.isOk()).toBe(true)
787+
expect(result.unwrapOr({})).toEqual({})
788+
})
789+
})
790+
describe('`ResultAsync.struct`', () => {
791+
it('returns Ok with all values when all results are Ok', async () => {
792+
const input = {
793+
a: okAsync(1),
794+
b: okAsync('test'),
795+
c: okAsync(true),
796+
}
797+
798+
const result = await ResultAsync.struct(input)
799+
800+
expect(result.isOk()).toBe(true)
801+
expect(result._unsafeUnwrap()).toEqual({ a: 1, b: 'test', c: true })
802+
})
803+
804+
it('returns Err with a single error when one result is Err', async () => {
805+
const input = {
806+
a: okAsync(1),
807+
b: errAsync('error1'),
808+
c: okAsync(true),
809+
}
810+
811+
const result = await ResultAsync.struct(input)
812+
813+
expect(result.isErr()).toBe(true)
814+
expect(result._unsafeUnwrapErr()).toEqual(['error1'])
815+
})
816+
817+
it('returns Err with all errors when some results are Err', async () => {
818+
const input = {
819+
a: okAsync(1),
820+
b: errAsync('error1'),
821+
c: errAsync('error2'),
822+
}
823+
824+
const result = await ResultAsync.struct(input)
825+
826+
expect(result.isErr()).toBe(true)
827+
expect(result._unsafeUnwrapErr()).toEqual(['error1', 'error2'])
828+
})
829+
830+
it('returns Ok with an empty object when input is an empty object', async () => {
831+
const input = {}
832+
833+
const result = await ResultAsync.struct(input)
834+
835+
expect(result.isOk()).toBe(true)
836+
expect(result.unwrapOr({})).toEqual({})
837+
})
838+
})
741839
})
742840

743841
describe('ResultAsync', () => {

0 commit comments

Comments
 (0)