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
5 changes: 5 additions & 0 deletions .changeset/add-do-bind-let.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"neverthrow": minor
---

Add Do / bind / let for Result and ResultAsync
29 changes: 29 additions & 0 deletions src/result-async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,35 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
) as CombineResultsWithAllErrorsArrayAsync<T>
}

// eslint-disable-next-line @typescript-eslint/ban-types
static get Do(): ResultAsync<{}, never> {
return okAsync({})
}

bind<N extends string, B, F>(
name: N,
f: (t: T) => Result<B, F> | ResultAsync<B, F>,
): ResultAsync<T & { readonly [K in N]: B }, E | F>
bind(
name: string,
f: (t: T) => Result<unknown, unknown> | ResultAsync<unknown, unknown>,
): ResultAsync<unknown, unknown> {
return this.andThen((val) =>
f(val).map((newVal) => ({
...((val as unknown) as Record<string, unknown>),
[name]: newVal,
})),
)
}

let<N extends string, B>(name: N, f: (t: T) => B): ResultAsync<T & { readonly [K in N]: B }, E>
let(name: string, f: (t: T) => unknown): ResultAsync<unknown, unknown> {
return this.map((val) => ({
...((val as unknown) as Record<string, unknown>),
[name]: f(val),
}))
}

map<A>(f: (t: T) => A | Promise<A>): ResultAsync<A, E> {
return new ResultAsync(
this._promise.then(async (res: Result<T, E>) => {
Expand Down
43 changes: 43 additions & 0 deletions src/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export namespace Result {
): CombineResultsWithAllErrorsArray<T> {
return combineResultListWithAllErrors(resultList) as CombineResultsWithAllErrorsArray<T>
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function Do(): Result<{}, never> {
return ok({})
}
}

export type Result<T, E> = Ok<T, E> | Err<T, E>
Expand Down Expand Up @@ -275,6 +279,13 @@ interface IResult<T, E> {
*/
match<A, B = A>(ok: (t: T) => A, err: (e: E) => B): A | B

bind<N extends string, B, F>(
name: N,
f: (t: T) => Result<B, F>,
): Result<T & { readonly [K in N]: B }, E | F>

let<N extends string, B>(name: N, f: (t: T) => B): Result<T & { readonly [K in N]: B }, E>

/**
* @deprecated will be removed in 9.0.0.
*
Expand Down Expand Up @@ -367,6 +378,25 @@ export class Ok<T, E> implements IResult<T, E> {
return ok(this.value)
}

bind<N extends string, B, F>(
name: N,
f: (t: T) => Result<B, F>,
): Result<T & { readonly [K in N]: B }, E | F>
bind(name: string, f: (t: T) => Result<unknown, unknown>): Result<unknown, unknown> {
return f(this.value).map((val) => ({
...((this.value as unknown) as Record<string, unknown>),
[name]: val,
}))
}

let<N extends string, B>(name: N, f: (t: T) => B): Result<T & { readonly [K in N]: B }, E>
let(name: string, f: (t: T) => unknown): Result<unknown, unknown> {
return ok({
...((this.value as unknown) as Record<string, unknown>),
[name]: f(this.value),
})
}

asyncAndThen<U, F>(f: (t: T) => ResultAsync<U, F>): ResultAsync<U, E | F> {
return f(this.value)
}
Expand Down Expand Up @@ -471,6 +501,19 @@ export class Err<T, E> implements IResult<T, E> {
return f(this.error)
}

bind<N extends string, B, F>(
name: N,
f: (t: T) => Result<B, F>,
): Result<T & { readonly [K in N]: B }, E | F>
bind(_name: string, _f: (t: T) => Result<unknown, unknown>): Result<unknown, unknown> {
return err(this.error)
}

let<N extends string, B>(name: N, f: (t: T) => B): Result<T & { readonly [K in N]: B }, E>
let(_name: string, _f: (t: T) => unknown): Result<unknown, unknown> {
return err(this.error)
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
asyncAndThen<U, F>(_f: (t: T) => ResultAsync<U, F>): ResultAsync<U, E | F> {
return errAsync<U, E>(this.error)
Expand Down
205 changes: 205 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1295,3 +1295,208 @@ describe('ResultAsync', () => {
})
})
})

describe('Result.Do / bind / let', () => {
it('Accumulates values with bind', () => {
const result = Result.Do()
.bind('x', () => ok(1))
.bind('y', ({ x }) => ok(x + 1))

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({ x: 1, y: 2 })
})

it('Short-circuits on first Err', () => {
const result = Result.Do()
.bind('x', () => ok(1))
.bind('y', () => err('fail' as const))
.bind('z', () => ok(3))

expect(result.isErr()).toBe(true)
expect(result._unsafeUnwrapErr()).toEqual('fail')
})

it('Does not call subsequent callbacks after Err', () => {
const spy = vitest.fn(() => ok(3))

Result.Do()
.bind('x', () => ok(1))
.bind('y', () => err('fail'))
.bind('z', spy)

expect(spy).not.toHaveBeenCalled()
})

it('Adds pure values with let', () => {
const result = Result.Do()
.bind('x', () => ok(10))
.let('doubled', ({ x }) => x * 2)

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({ x: 10, doubled: 20 })
})

it('Combines bind and let', () => {
const result = Result.Do()
.bind('name', () => ok('Alice'))
.bind('age', () => ok(30))
.let('greeting', ({ name, age }) => `${name} is ${age}`)

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({
name: 'Alice',
age: 30,
greeting: 'Alice is 30',
})
})

it('let short-circuits when already Err', () => {
const result = Result.Do()
.bind('x', () => err('fail' as const))
.let('y', () => 42)

expect(result.isErr()).toBe(true)
expect(result._unsafeUnwrapErr()).toEqual('fail')
})

it('Accumulates different error types as union', () => {
const getUser = (): Result<{ name: string }, 'user-not-found'> => ok({ name: 'Alice' })
const getRole = (_user: { name: string }): Result<string, 'role-error'> => ok('admin')

const result = Result.Do()
.bind('user', () => getUser())
.bind('role', ({ user }) => getRole(user))

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({ user: { name: 'Alice' }, role: 'admin' })
})

it('Short-circuits mid-chain with correct error', () => {
const result = Result.Do()
.bind('a', () => ok(1))
.bind('b', () => err('mid-error' as const))
.let('c', ({ a }) => a * 10)
.bind('d', () => ok(4))

expect(result.isErr()).toBe(true)
expect(result._unsafeUnwrapErr()).toEqual('mid-error')
})

it('Do returns an empty object', () => {
const result = Result.Do()

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({})
})

it('Chains map after bind', () => {
const result = Result.Do()
.bind('x', () => ok(2))
.bind('y', () => ok(3))
.map(({ x, y }) => x * y)

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual(6)
})

it('Chains andThen after bind', () => {
const result = Result.Do()
.bind('x', () => ok(5))
.andThen(({ x }) => ok(x * 2))

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual(10)
})
})

describe('ResultAsync.Do / bind / let', () => {
it('Accumulates values with bind', async () => {
const result = await ResultAsync.Do
.bind('x', () => okAsync(1))
.bind('y', ({ x }) => okAsync(x + 1))

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({ x: 1, y: 2 })
})

it('Short-circuits on first Err', async () => {
const result = await ResultAsync.Do
.bind('x', () => okAsync(1))
.bind('y', () => errAsync('fail' as const))
.bind('z', () => okAsync(3))

expect(result.isErr()).toBe(true)
expect(result._unsafeUnwrapErr()).toEqual('fail')
})

it('Does not call subsequent callbacks after Err', async () => {
const spy = vitest.fn(() => okAsync(3))

await ResultAsync.Do
.bind('x', () => okAsync(1))
.bind('y', () => errAsync('fail'))
.bind('z', spy)

expect(spy).not.toHaveBeenCalled()
})

it('Adds pure values with let', async () => {
const result = await ResultAsync.Do
.bind('x', () => okAsync(10))
.let('doubled', ({ x }) => x * 2)

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({ x: 10, doubled: 20 })
})

it('Combines bind and let', async () => {
const result = await ResultAsync.Do
.bind('name', () => okAsync('Bob'))
.bind('age', () => okAsync(25))
.let('label', ({ name, age }) => `${name}:${age}`)

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({
name: 'Bob',
age: 25,
label: 'Bob:25',
})
})

it('let short-circuits when already Err', async () => {
const result = await ResultAsync.Do
.bind('x', () => errAsync('fail' as const))
.let('y', () => 42)

expect(result.isErr()).toBe(true)
expect(result._unsafeUnwrapErr()).toEqual('fail')
})

it('Accepts sync Result in bind', async () => {
const result = await ResultAsync.Do
.bind('x', () => ok(1))
.bind('y', ({ x }) => okAsync(x + 1))

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual({ x: 1, y: 2 })
})

it('Chains map after bind', async () => {
const result = await ResultAsync.Do
.bind('x', () => okAsync(2))
.bind('y', () => okAsync(3))
.map(({ x, y }) => x * y)

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual(6)
})

it('Chains andThen after bind', async () => {
const result = await ResultAsync.Do
.bind('x', () => okAsync(5))
.andThen(({ x }) => okAsync(x * 2))

expect(result.isOk()).toBe(true)
expect(result._unsafeUnwrap()).toEqual(10)
})
})
Loading