-
Notifications
You must be signed in to change notification settings - Fork 12
feat(result): add AsyncResult for point-free async #209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
3751065
6529d9e
735d293
aad4a9b
4f103c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { AsyncResult } from './async-result' | ||
|
||
describe('AsyncResult.all (short-circuit)', () => { | ||
it('should short-circuit and not wait for later items after a failure', async () => { | ||
const a = AsyncResult.ok<number, string>(1) | ||
const bad = AsyncResult.fail<number, string>('boom') | ||
|
||
const slow = AsyncResult.fromPromise<number, string>( | ||
new Promise<number>(resolve => setTimeout(() => resolve(99), 200)) | ||
) | ||
|
||
const start = Date.now() | ||
const res = await AsyncResult.all([a, bad, slow]).toPromise() | ||
const elapsed = Date.now() - start | ||
|
||
expect(res.isFail()).toBe(true) | ||
expect(res.unwrapFail()).toBe('boom') | ||
// Should return well before the slow item resolves (200ms). Allow a generous margin. | ||
expect(elapsed).toBeLessThan(150) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
import { ok, fail } from './result.factory' | ||
import { AsyncResult } from './async-result' | ||
|
||
describe(AsyncResult.name, () => { | ||
it('should allow point-free async chaining with map/mapAsync/flatMap/flatMapAsync', async () => { | ||
const res = AsyncResult.ok<number, string>(2) | ||
.map(n => n + 1) // 3 | ||
.mapAsync(async n => n * 2) // 6 | ||
.flatMap(x => ok<string, string>(String(x))) // Ok('6') | ||
.flatMapAsync(async s => ok<string, string>(s + '!')) // Ok('6!') | ||
|
||
const final = await res.toPromise() | ||
expect(final.isOk()).toBe(true) | ||
expect(final.unwrap()).toBe('6!') | ||
}) | ||
|
||
it('should propagate failures across async boundaries', async () => { | ||
const res = AsyncResult.ok<number, string>(1) | ||
.mapAsync(async () => { | ||
throw 'bad' | ||
}) | ||
.map(n => n + 1) | ||
.flatMap(() => ok<number, string>(999)) | ||
|
||
const final = await res.toPromise() | ||
expect(final.isFail()).toBe(true) | ||
expect(final.unwrapFail()).toBe('bad') | ||
}) | ||
|
||
it('fromPromise should wrap resolve/reject into Result and support chaining', async () => { | ||
const okAr = AsyncResult.fromPromise<number, string>(Promise.resolve(5)).map(n => n * 2) | ||
const okFinal = await okAr.toPromise() | ||
expect(okFinal.isOk()).toBe(true) | ||
expect(okFinal.unwrap()).toBe(10) | ||
|
||
const failAr = AsyncResult.fromPromise<number, string>(Promise.reject('nope')).map(n => n * 2) | ||
const failFinal = await failAr.toPromise() | ||
expect(failFinal.isFail()).toBe(true) | ||
expect(failFinal.unwrapFail()).toBe('nope') | ||
}) | ||
|
||
it('flatMapAsync should flatten Promise<Result<...>>', async () => { | ||
const ar = AsyncResult.ok<number, string>(1).flatMapAsync(async (n) => { | ||
return n > 0 ? ok<number, string>(n + 1) : fail<number, string>('neg') | ||
}) | ||
|
||
const final = await ar.toPromise() | ||
expect(final.isOk()).toBe(true) | ||
expect(final.unwrap()).toBe(2) | ||
}) | ||
|
||
it('chain should accept a function returning AsyncResult and keep it point-free', async () => { | ||
const incAsync = (n: number): AsyncResult<number, string> => AsyncResult.ok<number, string>(n + 1) | ||
|
||
const ar = AsyncResult.ok<number, string>(3) | ||
.chain(incAsync) | ||
.chain(incAsync) | ||
|
||
const final = await ar.toPromise() | ||
expect(final.isOk()).toBe(true) | ||
expect(final.unwrap()).toBe(5) | ||
}) | ||
|
||
it('chain should convert sync throws in the callback into Fail (no rejection)', async () => { | ||
const boom = (n: number): AsyncResult<number, string> => { | ||
void n | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
throw 'oops' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
const ar = AsyncResult.ok<number, string>(1).chain(boom) | ||
const final = await ar.toPromise() | ||
expect(final.isFail()).toBe(true) | ||
expect(final.unwrapFail()).toBe('oops') | ||
}) | ||
|
||
it('all should collect Ok values and fail on the first failure', async () => { | ||
const a = AsyncResult.ok<number, string>(1) | ||
const b = AsyncResult.fromPromise<number, string>(Promise.resolve(2)) | ||
const c = AsyncResult.ok<number, string>(3) | ||
|
||
const allOk = await AsyncResult.all([a, b, c]).toPromise() | ||
expect(allOk.isOk()).toBe(true) | ||
expect(allOk.unwrap()).toEqual([1, 2, 3]) | ||
|
||
const bad = AsyncResult.fail<number, string>('oops') | ||
const allFail = await AsyncResult.all([a, bad, c]).toPromise() | ||
expect(allFail.isFail()).toBe(true) | ||
expect(allFail.unwrapFail()).toBe('oops') | ||
}) | ||
|
||
it('all should short-circuit on first Fail (does not wait for later items)', async () => { | ||
let thirdCompleted = false | ||
const a = AsyncResult.ok<number, string>(1) | ||
const b = AsyncResult.fromPromise<number, string>( | ||
new Promise<number>((_, reject) => setTimeout(() => reject('boom'), 10)) | ||
) | ||
const c = AsyncResult.fromPromise<number, string>( | ||
new Promise<number>((resolve) => setTimeout(() => { | ||
thirdCompleted = true | ||
resolve(3) | ||
}, 1000)) | ||
) | ||
|
||
const startedAt = Date.now() | ||
const res = await AsyncResult.all([a, b, c]).toPromise() | ||
const elapsed = Date.now() - startedAt | ||
|
||
expect(res.isFail()).toBe(true) | ||
expect(res.unwrapFail()).toBe('boom') | ||
// Should finish well before the third completes (1000ms) | ||
expect(elapsed).toBeLessThan(500) | ||
// And third should not be marked completed yet at the moment of resolution | ||
expect(thirdCompleted).toBe(false) | ||
Comment on lines
+103
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test already uses a side-effect flag ( SuggestionRemove the elapsed-time measurement and assertion, keeping the side-effect check: const res = await AsyncResult.all([a, b, c]).toPromise()
expect(res.isFail()).toBe(true)
expect(res.unwrapFail()).toBe('boom')
// And third should not be marked completed yet at the moment of resolution
expect(thirdCompleted).toBe(false) Reply with "@CharlieHelps yes please" if you’d like me to make this test less flaky. |
||
}) | ||
|
||
it('fromResult and fromResultPromise should wrap existing Results', async () => { | ||
const syncOk = AsyncResult.fromResult(ok<number, string>(1)) | ||
const okFinal = await syncOk.toPromise() | ||
expect(okFinal.isOk()).toBe(true) | ||
expect(okFinal.unwrap()).toBe(1) | ||
|
||
const syncFail = AsyncResult.fromResult(fail<number, string>('e')) | ||
const failFinal = await syncFail.toPromise() | ||
expect(failFinal.isFail()).toBe(true) | ||
expect(failFinal.unwrapFail()).toBe('e') | ||
|
||
const p = Promise.resolve(ok<number, string>(7)) | ||
const fromPromiseResult = await AsyncResult.fromResultPromise(p).toPromise() | ||
expect(fromPromiseResult.isOk()).toBe(true) | ||
expect(fromPromiseResult.unwrap()).toBe(7) | ||
|
||
const viaFromResult = await AsyncResult.fromResult(Promise.resolve(ok<number, string>(42))).toPromise() | ||
expect(viaFromResult.isOk()).toBe(true) | ||
expect(viaFromResult.unwrap()).toBe(42) | ||
}) | ||
|
||
it('fromResult should convert a rejecting Promise<IResult> into Fail (non-throwing invariant)', async () => { | ||
const rejecting = Promise.reject('reject-me') as Promise<ReturnType<typeof ok<number, string>>> // type doesn't matter here | ||
const res = await AsyncResult.fromResult<number, string>(rejecting).toPromise() | ||
expect(res.isFail()).toBe(true) | ||
expect(res.unwrapFail()).toBe('reject-me') | ||
}) | ||
|
||
it('fromResultPromise should convert a rejecting Promise<IResult> into Fail (non-throwing invariant)', async () => { | ||
const rejecting = Promise.reject('nope') as unknown as Promise<ReturnType<typeof ok<number, string>>> | ||
const res = await AsyncResult.fromResultPromise<number, string>(rejecting).toPromise() | ||
expect(res.isFail()).toBe(true) | ||
expect(res.unwrapFail()).toBe('nope') | ||
}) | ||
|
||
it('mapFail should transform the error', async () => { | ||
const ar = AsyncResult.fail<number, Error>(new Error('x')).mapFail(e => e.message) | ||
const final = await ar.toPromise() | ||
expect(final.isFail()).toBe(true) | ||
expect(final.unwrapFail()).toBe('x') | ||
}) | ||
|
||
it('flatMapAsync should catch thrown/rejected errors and convert to Fail', async () => { | ||
const ar = AsyncResult.ok<number, string>(1).flatMapAsync(async () => { | ||
throw 'oops' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
}) | ||
const final = await ar.toPromise() | ||
expect(final.isFail()).toBe(true) | ||
expect(final.unwrapFail()).toBe('oops') | ||
}) | ||
|
||
it('flatMapAsync and chain should short-circuit when initial AsyncResult is Fail', async () => { | ||
const fm = await AsyncResult.fail<number, string>('bad') | ||
.flatMapAsync(async n => ok<number, string>(n + 1)) | ||
.toPromise() | ||
expect(fm.isFail()).toBe(true) | ||
expect(fm.unwrapFail()).toBe('bad') | ||
|
||
const chained = await AsyncResult.fail<number, string>('bad') | ||
.chain(n => AsyncResult.ok<number, string>(n + 1)) | ||
.toPromise() | ||
expect(chained.isFail()).toBe(true) | ||
expect(chained.unwrapFail()).toBe('bad') | ||
}) | ||
|
||
it('match and matchAsync should resolve with the proper branch', async () => { | ||
const m1 = await AsyncResult.ok<number, string>(2).match({ ok: n => n * 2, fail: (e) => { void e; return -1 } }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
expect(m1).toBe(4) | ||
|
||
const m2 = await AsyncResult.ok<number, string>(3).matchAsync({ ok: async n => n * 3, fail: async (e) => { void e; return -1 } }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
expect(m2).toBe(9) | ||
|
||
const m3 = await AsyncResult.fail<number, string>('x').matchAsync({ ok: async (n) => { void n; return 0 }, fail: async e => e.length }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
expect(m3).toBe(1) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import type { IResult } from './result.interface' | ||
import { Result } from './result' | ||
|
||
/** | ||
* AsyncResult is a thin wrapper around Promise<Result<Ok, Fail>> that preserves | ||
* the point-free, chainable API developers expect from Result while crossing | ||
* async boundaries. Methods return AsyncResult so you can keep chaining without | ||
* mixing in `.then` calls until the very end (use `toPromise()` to unwrap). | ||
*/ | ||
export class AsyncResult<TOk, TFail> { | ||
private readonly promise: Promise<IResult<TOk, TFail>> | ||
|
||
private constructor(promise: Promise<IResult<TOk, TFail>>) { | ||
this.promise = promise | ||
} | ||
|
||
// Constructors / factories | ||
static ok<TOk, TFail = never>(value: TOk): AsyncResult<TOk, TFail> { | ||
return new AsyncResult<TOk, TFail>(Promise.resolve(Result.ok<TOk, TFail>(value))) | ||
} | ||
|
||
static fail<TOk = never, TFail = unknown>(error: TFail): AsyncResult<TOk, TFail> { | ||
return new AsyncResult<TOk, TFail>(Promise.resolve(Result.fail<TOk, TFail>(error))) | ||
} | ||
|
||
static fromResult<TOk, TFail>(result: IResult<TOk, TFail> | Promise<IResult<TOk, TFail>>): AsyncResult<TOk, TFail> { | ||
// Normalize sync values and thenables; convert unexpected rejections into Fail | ||
const p = Promise | ||
.resolve(result) | ||
.catch((e) => Result.fail<TOk, TFail>(e as TFail)) | ||
return new AsyncResult<TOk, TFail>(p) | ||
} | ||
|
||
static fromResultPromise<TOk, TFail>(promise: Promise<IResult<TOk, TFail>>): AsyncResult<TOk, TFail> { | ||
const p = promise.catch((e) => Result.fail<TOk, TFail>(e as TFail)) | ||
return new AsyncResult<TOk, TFail>(p) | ||
} | ||
|
||
static fromPromise<TOk, TFail = unknown>(promise: Promise<TOk>): AsyncResult<TOk, TFail> { | ||
return new AsyncResult<TOk, TFail>(Result.fromPromise<TOk, TFail>(promise)) | ||
} | ||
|
||
/** | ||
* Aggregate a list of AsyncResult values, short-circuiting on the first Fail. | ||
* Note: evaluation is sequential to enable early exit; this method does not | ||
* guarantee concurrent execution of inputs. If all are Ok, returns Ok of the | ||
* collected values preserving order; otherwise returns the first Fail seen. | ||
*/ | ||
static all<T, E>(items: ReadonlyArray<AsyncResult<T, E>>): AsyncResult<ReadonlyArray<T>, E> { | ||
const run = async (): Promise<IResult<ReadonlyArray<T>, E>> => { | ||
const acc: T[] = [] | ||
for (const ar of items) { | ||
const r = await ar.toPromise() | ||
if (r.isFail()) return Result.fail<ReadonlyArray<T>, E>(r.unwrapFail()) | ||
acc.push(r.unwrap()) | ||
} | ||
return Result.ok<ReadonlyArray<T>, E>(acc) | ||
} | ||
return new AsyncResult<ReadonlyArray<T>, E>(run()) | ||
} | ||
|
||
// Core instance methods | ||
map<M>(fn: (val: TOk) => M): AsyncResult<M, TFail> { | ||
const p = this.promise.then(r => r.map(fn)) | ||
return new AsyncResult<M, TFail>(p) | ||
} | ||
|
||
mapFail<M>(fn: (err: TFail) => M): AsyncResult<TOk, M> { | ||
const p = this.promise.then(r => r.mapFail(fn)) | ||
return new AsyncResult<TOk, M>(p) | ||
} | ||
|
||
flatMap<M>(fn: (val: TOk) => IResult<M, TFail>): AsyncResult<M, TFail> { | ||
const p = this.promise.then(r => r.flatMap(fn)) | ||
return new AsyncResult<M, TFail>(p) | ||
} | ||
|
||
mapAsync<M>(fn: (val: TOk) => Promise<M>): AsyncResult<M, TFail> { | ||
// Delegate to flatMapAsync so async error handling is centralized. | ||
return this.flatMapAsync(async (v) => Result.ok<M, TFail>(await fn(v))) | ||
} | ||
|
||
flatMapAsync<M>(fn: (val: TOk) => Promise<IResult<M, TFail>>): AsyncResult<M, TFail> { | ||
const p = this.promise.then(async r => { | ||
if (r.isOk()) { | ||
try { | ||
const next = await fn(r.unwrap()) | ||
return next | ||
} catch (e) { | ||
return Result.fail<M, TFail>(e as TFail) | ||
} | ||
} | ||
return Result.fail<M, TFail>(r.unwrapFail()) | ||
}) | ||
return new AsyncResult<M, TFail>(p) | ||
} | ||
|
||
chain<M>(fn: (val: TOk) => AsyncResult<M, TFail>): AsyncResult<M, TFail> { | ||
const p = this.promise.then(async (r): Promise<IResult<M, TFail>> => { | ||
if (!r.isOk()) return Result.fail<M, TFail>(r.unwrapFail()) | ||
try { | ||
return await fn(r.unwrap()).toPromise() | ||
} catch (e) { | ||
return Result.fail<M, TFail>(e as TFail) | ||
} | ||
}) | ||
return new AsyncResult<M, TFail>(p) | ||
} | ||
|
||
match<M>(pattern: { ok: (val: TOk) => M; fail: (err: TFail) => M }): Promise<M> { | ||
return this.promise.then(r => r.match(pattern)) | ||
} | ||
|
||
matchAsync<M>(pattern: { ok: (val: TOk) => Promise<M>; fail: (err: TFail) => Promise<M> }): Promise<M> { | ||
return this.promise.then(r => (r.isOk() ? pattern.ok(r.unwrap()) : pattern.fail(r.unwrapFail()))) | ||
} | ||
Comment on lines
+114
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
SuggestionBroaden handler types and lift results through matchAsync<M>(pattern: { ok: (val: TOk) => M | Promise<M>; fail: (err: TFail) => M | Promise<M> }): Promise[M] {
return this.promise.then((r) => Promise.resolve(
r.isOk() ? pattern.ok(r.unwrap()) : pattern.fail(r.unwrapFail())
))
} Reply with "@CharlieHelps yes please" if you’d like me to implement this ergonomic improvement. |
||
|
||
toPromise(): Promise<IResult<TOk, TFail>> { | ||
return this.promise | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Expected an error object to be thrown. [radarlint-js:typescript:S3696]