Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/result/async-result.all.spec.ts
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)
})
})
190 changes: 190 additions & 0 deletions src/result/async-result.spec.ts
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'
Copy link

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]

})
.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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this use of the "void" operator. [radarlint-js:typescript:S3735]

throw 'oops'
Copy link

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]

}
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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test already uses a side-effect flag (thirdCompleted) to prove short-circuiting, but still relies on an elapsed-time assertion. The timing check is unnecessary and can flake on slower runners. Drop the elapsed-time assertion and keep the flag-based assertion for determinism.

Suggestion

Remove 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'
Copy link

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]

})
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 } })
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this use of the "void" operator. [radarlint-js:typescript:S3735]

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 } })
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this use of the "void" operator. [radarlint-js:typescript:S3735]

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 })
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this use of the "void" operator. [radarlint-js:typescript:S3735]

expect(m3).toBe(1)
})
})
121 changes: 121 additions & 0 deletions src/result/async-result.ts
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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matchAsync forces both handlers to be async (Promise<M>), which is ergonomically restrictive. Allowing M | Promise<M> for handlers and lifting via Promise.resolve enables mixing sync/async branches without ceremony and aligns with typical match APIs.

Suggestion

Broaden handler types and lift results through Promise.resolve:

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
}
}
1 change: 1 addition & 0 deletions src/result/public_api.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down