Skip to content

Commit 3751065

Browse files
committed
feat(result): add AsyncResult for point-free async chaining
- Introduce `AsyncResult<Ok, Fail>` wrapper around `Promise<Result<...>>` - Provide chainable `map`, `mapAsync`, `flatMap`, `flatMapAsync`, `chain`, `match`, `matchAsync`, `toPromise`, and `all` - Export `AsyncResult` via `src/result/public_api.ts` - Add comprehensive tests with 100% coverage Addresses the pain called out in #171 about point-free async ergonomics.
1 parent 2e0d3bc commit 3751065

File tree

3 files changed

+242
-0
lines changed

3 files changed

+242
-0
lines changed

src/result/async-result.spec.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { ok, fail } from './result.factory'
2+
import { AsyncResult } from './async-result'
3+
4+
describe(AsyncResult.name, () => {
5+
it('should allow point-free async chaining with map/mapAsync/flatMap/flatMapAsync', async () => {
6+
const res = AsyncResult.ok<number, string>(2)
7+
.map(n => n + 1) // 3
8+
.mapAsync(async n => n * 2) // 6
9+
.flatMap(x => ok<string, string>(String(x))) // Ok('6')
10+
.flatMapAsync(async s => ok<string, string>(s + '!')) // Ok('6!')
11+
12+
const final = await res.toPromise()
13+
expect(final.isOk()).toBe(true)
14+
expect(final.unwrap()).toBe('6!')
15+
})
16+
17+
it('should propagate failures across async boundaries', async () => {
18+
const res = AsyncResult.ok<number, string>(1)
19+
.mapAsync(async () => {
20+
throw 'bad'
21+
})
22+
.map(n => n + 1)
23+
.flatMap(() => ok<number, string>(999))
24+
25+
const final = await res.toPromise()
26+
expect(final.isFail()).toBe(true)
27+
expect(final.unwrapFail()).toBe('bad')
28+
})
29+
30+
it('fromPromise should wrap resolve/reject into Result and support chaining', async () => {
31+
const okAr = AsyncResult.fromPromise<number, string>(Promise.resolve(5)).map(n => n * 2)
32+
const okFinal = await okAr.toPromise()
33+
expect(okFinal.isOk()).toBe(true)
34+
expect(okFinal.unwrap()).toBe(10)
35+
36+
const failAr = AsyncResult.fromPromise<number, string>(Promise.reject('nope')).map(n => n * 2)
37+
const failFinal = await failAr.toPromise()
38+
expect(failFinal.isFail()).toBe(true)
39+
expect(failFinal.unwrapFail()).toBe('nope')
40+
})
41+
42+
it('flatMapAsync should flatten Promise<Result<...>>', async () => {
43+
const ar = AsyncResult.ok<number, string>(1).flatMapAsync(async (n) => {
44+
return n > 0 ? ok<number, string>(n + 1) : fail<number, string>('neg')
45+
})
46+
47+
const final = await ar.toPromise()
48+
expect(final.isOk()).toBe(true)
49+
expect(final.unwrap()).toBe(2)
50+
})
51+
52+
it('chain should accept a function returning AsyncResult and keep it point-free', async () => {
53+
const incAsync = (n: number): AsyncResult<number, string> => AsyncResult.ok<number, string>(n + 1)
54+
55+
const ar = AsyncResult.ok<number, string>(3)
56+
.chain(incAsync)
57+
.chain(incAsync)
58+
59+
const final = await ar.toPromise()
60+
expect(final.isOk()).toBe(true)
61+
expect(final.unwrap()).toBe(5)
62+
})
63+
64+
it('all should collect Ok values and fail on the first failure', async () => {
65+
const a = AsyncResult.ok<number, string>(1)
66+
const b = AsyncResult.fromPromise<number, string>(Promise.resolve(2))
67+
const c = AsyncResult.ok<number, string>(3)
68+
69+
const allOk = await AsyncResult.all([a, b, c]).toPromise()
70+
expect(allOk.isOk()).toBe(true)
71+
expect(allOk.unwrap()).toEqual([1, 2, 3])
72+
73+
const bad = AsyncResult.fail<number, string>('oops')
74+
const allFail = await AsyncResult.all([a, bad, c]).toPromise()
75+
expect(allFail.isFail()).toBe(true)
76+
expect(allFail.unwrapFail()).toBe('oops')
77+
})
78+
79+
it('fromResult and fromResultPromise should wrap existing Results', async () => {
80+
const syncOk = AsyncResult.fromResult(ok<number, string>(1))
81+
const okFinal = await syncOk.toPromise()
82+
expect(okFinal.isOk()).toBe(true)
83+
expect(okFinal.unwrap()).toBe(1)
84+
85+
const syncFail = AsyncResult.fromResult(fail<number, string>('e'))
86+
const failFinal = await syncFail.toPromise()
87+
expect(failFinal.isFail()).toBe(true)
88+
expect(failFinal.unwrapFail()).toBe('e')
89+
90+
const p = Promise.resolve(ok<number, string>(7))
91+
const fromPromiseResult = await AsyncResult.fromResultPromise(p).toPromise()
92+
expect(fromPromiseResult.isOk()).toBe(true)
93+
expect(fromPromiseResult.unwrap()).toBe(7)
94+
95+
const viaFromResult = await AsyncResult.fromResult(Promise.resolve(ok<number, string>(42))).toPromise()
96+
expect(viaFromResult.isOk()).toBe(true)
97+
expect(viaFromResult.unwrap()).toBe(42)
98+
})
99+
100+
it('mapFail should transform the error', async () => {
101+
const ar = AsyncResult.fail<number, Error>(new Error('x')).mapFail(e => e.message)
102+
const final = await ar.toPromise()
103+
expect(final.isFail()).toBe(true)
104+
expect(final.unwrapFail()).toBe('x')
105+
})
106+
107+
it('flatMapAsync should catch thrown/rejected errors and convert to Fail', async () => {
108+
const ar = AsyncResult.ok<number, string>(1).flatMapAsync(async () => {
109+
throw 'oops'
110+
})
111+
const final = await ar.toPromise()
112+
expect(final.isFail()).toBe(true)
113+
expect(final.unwrapFail()).toBe('oops')
114+
})
115+
116+
it('flatMapAsync and chain should short-circuit when initial AsyncResult is Fail', async () => {
117+
const fm = await AsyncResult.fail<number, string>('bad')
118+
.flatMapAsync(async n => ok<number, string>(n + 1))
119+
.toPromise()
120+
expect(fm.isFail()).toBe(true)
121+
expect(fm.unwrapFail()).toBe('bad')
122+
123+
const chained = await AsyncResult.fail<number, string>('bad')
124+
.chain(n => AsyncResult.ok<number, string>(n + 1))
125+
.toPromise()
126+
expect(chained.isFail()).toBe(true)
127+
expect(chained.unwrapFail()).toBe('bad')
128+
})
129+
130+
it('match and matchAsync should resolve with the proper branch', async () => {
131+
const m1 = await AsyncResult.ok<number, string>(2).match({ ok: n => n * 2, fail: _ => -1 })
132+
expect(m1).toBe(4)
133+
134+
const m2 = await AsyncResult.ok<number, string>(3).matchAsync({ ok: async n => n * 3, fail: async _ => -1 })
135+
expect(m2).toBe(9)
136+
137+
const m3 = await AsyncResult.fail<number, string>('x').matchAsync({ ok: async _ => 0, fail: async e => e.length })
138+
expect(m3).toBe(1)
139+
})
140+
})

src/result/async-result.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { IResult } from './result.interface'
2+
import { Result } from './result'
3+
4+
/**
5+
* AsyncResult is a thin wrapper around Promise<Result<Ok, Fail>> that preserves
6+
* the point-free, chainable API developers expect from Result while crossing
7+
* async boundaries. Methods return AsyncResult so you can keep chaining without
8+
* mixing in `.then` calls until the very end (use `toPromise()` to unwrap).
9+
*/
10+
export class AsyncResult<TOk, TFail> {
11+
private readonly promise: Promise<IResult<TOk, TFail>>
12+
13+
private constructor(promise: Promise<IResult<TOk, TFail>>) {
14+
this.promise = promise
15+
}
16+
17+
// Constructors / factories
18+
static ok<TOk, TFail = never>(value: TOk): AsyncResult<TOk, TFail> {
19+
return new AsyncResult<TOk, TFail>(Promise.resolve(Result.ok<TOk, TFail>(value)))
20+
}
21+
22+
static fail<TOk = never, TFail = unknown>(error: TFail): AsyncResult<TOk, TFail> {
23+
return new AsyncResult<TOk, TFail>(Promise.resolve(Result.fail<TOk, TFail>(error)))
24+
}
25+
26+
static fromResult<TOk, TFail>(result: IResult<TOk, TFail> | Promise<IResult<TOk, TFail>>): AsyncResult<TOk, TFail> {
27+
const p = result instanceof Promise ? result : Promise.resolve(result)
28+
return new AsyncResult<TOk, TFail>(p)
29+
}
30+
31+
static fromResultPromise<TOk, TFail>(promise: Promise<IResult<TOk, TFail>>): AsyncResult<TOk, TFail> {
32+
return new AsyncResult<TOk, TFail>(promise)
33+
}
34+
35+
static fromPromise<TOk, TFail = unknown>(promise: Promise<TOk>): AsyncResult<TOk, TFail> {
36+
return new AsyncResult<TOk, TFail>(Result.fromPromise<TOk, TFail>(promise))
37+
}
38+
39+
static all<T, E>(items: ReadonlyArray<AsyncResult<T, E>>): AsyncResult<ReadonlyArray<T>, E> {
40+
const p = Promise.all(items.map(i => i.promise)).then(results => Result.sequence(results))
41+
return new AsyncResult<ReadonlyArray<T>, E>(p)
42+
}
43+
44+
// Core instance methods
45+
map<M>(fn: (val: TOk) => M): AsyncResult<M, TFail> {
46+
const p = this.promise.then(r => r.map(fn))
47+
return new AsyncResult<M, TFail>(p)
48+
}
49+
50+
mapFail<M>(fn: (err: TFail) => M): AsyncResult<TOk, M> {
51+
const p = this.promise.then(r => r.mapFail(fn))
52+
return new AsyncResult<TOk, M>(p)
53+
}
54+
55+
flatMap<M>(fn: (val: TOk) => IResult<M, TFail>): AsyncResult<M, TFail> {
56+
const p = this.promise.then(r => r.flatMap(fn))
57+
return new AsyncResult<M, TFail>(p)
58+
}
59+
60+
mapAsync<M>(fn: (val: TOk) => Promise<M>): AsyncResult<M, TFail> {
61+
const p = this.promise.then(r => r.flatMapPromise(fn))
62+
return new AsyncResult<M, TFail>(p)
63+
}
64+
65+
flatMapAsync<M>(fn: (val: TOk) => Promise<IResult<M, TFail>>): AsyncResult<M, TFail> {
66+
const p = this.promise.then(async r => {
67+
if (r.isOk()) {
68+
try {
69+
const next = await fn(r.unwrap())
70+
return next
71+
} catch (e) {
72+
return Result.fail<M, TFail>(e as TFail)
73+
}
74+
}
75+
return Result.fail<M, TFail>(r.unwrapFail())
76+
})
77+
return new AsyncResult<M, TFail>(p)
78+
}
79+
80+
chain<M>(fn: (val: TOk) => AsyncResult<M, TFail>): AsyncResult<M, TFail> {
81+
const p = this.promise.then(r => {
82+
if (r.isOk()) {
83+
return fn(r.unwrap()).promise
84+
}
85+
return Promise.resolve(Result.fail<M, TFail>(r.unwrapFail()))
86+
})
87+
return new AsyncResult<M, TFail>(p)
88+
}
89+
90+
match<M>(pattern: { ok: (val: TOk) => M; fail: (err: TFail) => M }): Promise<M> {
91+
return this.promise.then(r => r.match(pattern))
92+
}
93+
94+
matchAsync<M>(pattern: { ok: (val: TOk) => Promise<M>; fail: (err: TFail) => Promise<M> }): Promise<M> {
95+
return this.promise.then(r => (r.isOk() ? pattern.ok(r.unwrap()) : pattern.fail(r.unwrapFail())))
96+
}
97+
98+
toPromise(): Promise<IResult<TOk, TFail>> {
99+
return this.promise
100+
}
101+
}

src/result/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './result'
22
export * from './result.factory'
33
export * from './result.interface'
4+
export * from './async-result'
45
export * from './transformers/result-to-promise'
56
export * from './transformers/try-catch-to-result'
67
export * from './transformers/unwrap-result'

0 commit comments

Comments
 (0)