diff --git a/src/result/async-result.all.spec.ts b/src/result/async-result.all.spec.ts new file mode 100644 index 0000000..413158d --- /dev/null +++ b/src/result/async-result.all.spec.ts @@ -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(1) + const bad = AsyncResult.fail('boom') + + const slow = AsyncResult.fromPromise( + new Promise(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) + }) +}) diff --git a/src/result/async-result.spec.ts b/src/result/async-result.spec.ts new file mode 100644 index 0000000..faa888c --- /dev/null +++ b/src/result/async-result.spec.ts @@ -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(2) + .map(n => n + 1) // 3 + .mapAsync(async n => n * 2) // 6 + .flatMap(x => ok(String(x))) // Ok('6') + .flatMapAsync(async s => ok(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(1) + .mapAsync(async () => { + throw 'bad' + }) + .map(n => n + 1) + .flatMap(() => ok(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(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(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>', async () => { + const ar = AsyncResult.ok(1).flatMapAsync(async (n) => { + return n > 0 ? ok(n + 1) : fail('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 => AsyncResult.ok(n + 1) + + const ar = AsyncResult.ok(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 => { + void n + throw 'oops' + } + const ar = AsyncResult.ok(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(1) + const b = AsyncResult.fromPromise(Promise.resolve(2)) + const c = AsyncResult.ok(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('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(1) + const b = AsyncResult.fromPromise( + new Promise((_, reject) => setTimeout(() => reject('boom'), 10)) + ) + const c = AsyncResult.fromPromise( + new Promise((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) + }) + + it('fromResult and fromResultPromise should wrap existing Results', async () => { + const syncOk = AsyncResult.fromResult(ok(1)) + const okFinal = await syncOk.toPromise() + expect(okFinal.isOk()).toBe(true) + expect(okFinal.unwrap()).toBe(1) + + const syncFail = AsyncResult.fromResult(fail('e')) + const failFinal = await syncFail.toPromise() + expect(failFinal.isFail()).toBe(true) + expect(failFinal.unwrapFail()).toBe('e') + + const p = Promise.resolve(ok(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(42))).toPromise() + expect(viaFromResult.isOk()).toBe(true) + expect(viaFromResult.unwrap()).toBe(42) + }) + + it('fromResult should convert a rejecting Promise into Fail (non-throwing invariant)', async () => { + const rejecting = Promise.reject('reject-me') as Promise>> // type doesn't matter here + const res = await AsyncResult.fromResult(rejecting).toPromise() + expect(res.isFail()).toBe(true) + expect(res.unwrapFail()).toBe('reject-me') + }) + + it('fromResultPromise should convert a rejecting Promise into Fail (non-throwing invariant)', async () => { + const rejecting = Promise.reject('nope') as unknown as Promise>> + const res = await AsyncResult.fromResultPromise(rejecting).toPromise() + expect(res.isFail()).toBe(true) + expect(res.unwrapFail()).toBe('nope') + }) + + it('mapFail should transform the error', async () => { + const ar = AsyncResult.fail(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(1).flatMapAsync(async () => { + throw 'oops' + }) + 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('bad') + .flatMapAsync(async n => ok(n + 1)) + .toPromise() + expect(fm.isFail()).toBe(true) + expect(fm.unwrapFail()).toBe('bad') + + const chained = await AsyncResult.fail('bad') + .chain(n => AsyncResult.ok(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(2).match({ ok: n => n * 2, fail: (e) => { void e; return -1 } }) + expect(m1).toBe(4) + + const m2 = await AsyncResult.ok(3).matchAsync({ ok: async n => n * 3, fail: async (e) => { void e; return -1 } }) + expect(m2).toBe(9) + + const m3 = await AsyncResult.fail('x').matchAsync({ ok: async (n) => { void n; return 0 }, fail: async e => e.length }) + expect(m3).toBe(1) + }) +}) diff --git a/src/result/async-result.ts b/src/result/async-result.ts new file mode 100644 index 0000000..e96c6b0 --- /dev/null +++ b/src/result/async-result.ts @@ -0,0 +1,121 @@ +import type { IResult } from './result.interface' +import { Result } from './result' + +/** +* AsyncResult is a thin wrapper around Promise> 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 { + private readonly promise: Promise> + + private constructor(promise: Promise>) { + this.promise = promise + } + + // Constructors / factories + static ok(value: TOk): AsyncResult { + return new AsyncResult(Promise.resolve(Result.ok(value))) + } + + static fail(error: TFail): AsyncResult { + return new AsyncResult(Promise.resolve(Result.fail(error))) + } + + static fromResult(result: IResult | Promise>): AsyncResult { + // Normalize sync values and thenables; convert unexpected rejections into Fail + const p = Promise + .resolve(result) + .catch((e) => Result.fail(e as TFail)) + return new AsyncResult(p) + } + + static fromResultPromise(promise: Promise>): AsyncResult { + const p = promise.catch((e) => Result.fail(e as TFail)) + return new AsyncResult(p) + } + + static fromPromise(promise: Promise): AsyncResult { + return new AsyncResult(Result.fromPromise(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(items: ReadonlyArray>): AsyncResult, E> { + const run = async (): Promise, E>> => { + const acc: T[] = [] + for (const ar of items) { + const r = await ar.toPromise() + if (r.isFail()) return Result.fail, E>(r.unwrapFail()) + acc.push(r.unwrap()) + } + return Result.ok, E>(acc) + } + return new AsyncResult, E>(run()) + } + + // Core instance methods + map(fn: (val: TOk) => M): AsyncResult { + const p = this.promise.then(r => r.map(fn)) + return new AsyncResult(p) + } + + mapFail(fn: (err: TFail) => M): AsyncResult { + const p = this.promise.then(r => r.mapFail(fn)) + return new AsyncResult(p) + } + + flatMap(fn: (val: TOk) => IResult): AsyncResult { + const p = this.promise.then(r => r.flatMap(fn)) + return new AsyncResult(p) + } + + mapAsync(fn: (val: TOk) => Promise): AsyncResult { + // Delegate to flatMapAsync so async error handling is centralized. + return this.flatMapAsync(async (v) => Result.ok(await fn(v))) + } + + flatMapAsync(fn: (val: TOk) => Promise>): AsyncResult { + const p = this.promise.then(async r => { + if (r.isOk()) { + try { + const next = await fn(r.unwrap()) + return next + } catch (e) { + return Result.fail(e as TFail) + } + } + return Result.fail(r.unwrapFail()) + }) + return new AsyncResult(p) + } + + chain(fn: (val: TOk) => AsyncResult): AsyncResult { + const p = this.promise.then(async (r): Promise> => { + if (!r.isOk()) return Result.fail(r.unwrapFail()) + try { + return await fn(r.unwrap()).toPromise() + } catch (e) { + return Result.fail(e as TFail) + } + }) + return new AsyncResult(p) + } + + match(pattern: { ok: (val: TOk) => M; fail: (err: TFail) => M }): Promise { + return this.promise.then(r => r.match(pattern)) + } + + matchAsync(pattern: { ok: (val: TOk) => Promise; fail: (err: TFail) => Promise }): Promise { + return this.promise.then(r => (r.isOk() ? pattern.ok(r.unwrap()) : pattern.fail(r.unwrapFail()))) + } + + toPromise(): Promise> { + return this.promise + } +} diff --git a/src/result/public_api.ts b/src/result/public_api.ts index 3b2cbde..7b6b28b 100644 --- a/src/result/public_api.ts +++ b/src/result/public_api.ts @@ -1,6 +1,7 @@ export * from './result' export * from './result.factory' export * from './result.interface' +export * from './async-result' export * from './transformers/result-to-promise' export * from './transformers/try-catch-to-result' export * from './transformers/unwrap-result'