diff --git a/.changeset/add-do-bind-let.md b/.changeset/add-do-bind-let.md new file mode 100644 index 00000000..36025a71 --- /dev/null +++ b/.changeset/add-do-bind-let.md @@ -0,0 +1,5 @@ +--- +"neverthrow": minor +--- + +Add Do / bind / let for Result and ResultAsync diff --git a/src/result-async.ts b/src/result-async.ts index 20120d2b..df1a0288 100644 --- a/src/result-async.ts +++ b/src/result-async.ts @@ -86,6 +86,35 @@ export class ResultAsync implements PromiseLike> { ) as CombineResultsWithAllErrorsArrayAsync } + // eslint-disable-next-line @typescript-eslint/ban-types + static get Do(): ResultAsync<{}, never> { + return okAsync({}) + } + + bind( + name: N, + f: (t: T) => Result | ResultAsync, + ): ResultAsync + bind( + name: string, + f: (t: T) => Result | ResultAsync, + ): ResultAsync { + return this.andThen((val) => + f(val).map((newVal) => ({ + ...((val as unknown) as Record), + [name]: newVal, + })), + ) + } + + let(name: N, f: (t: T) => B): ResultAsync + let(name: string, f: (t: T) => unknown): ResultAsync { + return this.map((val) => ({ + ...((val as unknown) as Record), + [name]: f(val), + })) + } + map(f: (t: T) => A | Promise): ResultAsync { return new ResultAsync( this._promise.then(async (res: Result) => { diff --git a/src/result.ts b/src/result.ts index ad447caa..aebd4c95 100644 --- a/src/result.ts +++ b/src/result.ts @@ -57,6 +57,10 @@ export namespace Result { ): CombineResultsWithAllErrorsArray { return combineResultListWithAllErrors(resultList) as CombineResultsWithAllErrorsArray } + // eslint-disable-next-line @typescript-eslint/ban-types + export function Do(): Result<{}, never> { + return ok({}) + } } export type Result = Ok | Err @@ -275,6 +279,13 @@ interface IResult { */ match(ok: (t: T) => A, err: (e: E) => B): A | B + bind( + name: N, + f: (t: T) => Result, + ): Result + + let(name: N, f: (t: T) => B): Result + /** * @deprecated will be removed in 9.0.0. * @@ -367,6 +378,25 @@ export class Ok implements IResult { return ok(this.value) } + bind( + name: N, + f: (t: T) => Result, + ): Result + bind(name: string, f: (t: T) => Result): Result { + return f(this.value).map((val) => ({ + ...((this.value as unknown) as Record), + [name]: val, + })) + } + + let(name: N, f: (t: T) => B): Result + let(name: string, f: (t: T) => unknown): Result { + return ok({ + ...((this.value as unknown) as Record), + [name]: f(this.value), + }) + } + asyncAndThen(f: (t: T) => ResultAsync): ResultAsync { return f(this.value) } @@ -471,6 +501,19 @@ export class Err implements IResult { return f(this.error) } + bind( + name: N, + f: (t: T) => Result, + ): Result + bind(_name: string, _f: (t: T) => Result): Result { + return err(this.error) + } + + let(name: N, f: (t: T) => B): Result + let(_name: string, _f: (t: T) => unknown): Result { + return err(this.error) + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars asyncAndThen(_f: (t: T) => ResultAsync): ResultAsync { return errAsync(this.error) diff --git a/tests/index.test.ts b/tests/index.test.ts index 9f089a9d..8bca51ad 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -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 => 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) + }) +}) diff --git a/tests/typecheck-tests.ts b/tests/typecheck-tests.ts index 55a3cd3d..f48591f8 100644 --- a/tests/typecheck-tests.ts +++ b/tests/typecheck-tests.ts @@ -2603,5 +2603,109 @@ declare function transpose< //#endregion +(function describe(_ = 'Result.Do / bind / let') { + (function it(_ = 'Infers accumulated type through bind chain') { + type Expectation = Result<{} & { readonly x: number } & { readonly y: string }, 'e1' | 'e2'> + + const result: Expectation = Result.Do() + .bind('x', () => ok(1)) + .bind('y', () => ok('hello')) + + Test.checks([ + Test.check(), + ]) + }); + + (function it(_ = 'Infers let type without adding to error union') { + type Expectation = Result<{} & { readonly x: number } & { readonly doubled: number }, 'e1'> + + const result: Expectation = Result.Do() + .bind('x', () => ok(1)) + .let('doubled', ({ x }) => x * 2) + + Test.checks([ + Test.check(), + ]) + }); + + (function it(_ = 'Callback receives accumulated context') { + Result.Do() + .bind('x', () => ok(1)) + .bind('y', ({ x }) => { + const _check: number = x + return ok(x + 1) + }) + .let('z', ({ x, y }) => { + const _checkX: number = x + const _checkY: number = y + return x + y + }) + }); + + (function it(_ = 'map receives accumulated context') { + type Expectation = Result + + const result: Expectation = Result.Do() + .bind('x', () => ok(1)) + .bind('y', () => ok(2)) + .map(({ x, y }) => x + y) + + Test.checks([ + Test.check(), + ]) + }); +}); + +(function describe(_ = 'ResultAsync.Do / bind / let') { + (function it(_ = 'Infers accumulated type through bind chain') { + type Expectation = ResultAsync<{} & { readonly x: number } & { readonly y: string }, 'e1' | 'e2'> + + const result: Expectation = ResultAsync.Do + .bind('x', () => okAsync(1)) + .bind('y', () => okAsync('hello')) + + Test.checks([ + Test.check(), + ]) + }); + + (function it(_ = 'Accepts sync Result in bind') { + type Expectation = ResultAsync<{} & { readonly x: number } & { readonly y: string }, 'e1' | 'e2'> + + const result: Expectation = ResultAsync.Do + .bind('x', () => ok(1)) + .bind('y', () => okAsync('hello')) + + Test.checks([ + Test.check(), + ]) + }); + + (function it(_ = 'Infers let type') { + type Expectation = ResultAsync<{} & { readonly x: number } & { readonly doubled: number }, 'e1'> + + const result: Expectation = ResultAsync.Do + .bind('x', () => okAsync(1)) + .let('doubled', ({ x }) => x * 2) + + Test.checks([ + Test.check(), + ]) + }); + + (function it(_ = 'map receives accumulated context') { + type Expectation = ResultAsync + + const result: Expectation = ResultAsync.Do + .bind('x', () => okAsync(1)) + .bind('y', () => okAsync(2)) + .map(({ x, y }) => x + y) + + Test.checks([ + Test.check(), + ]) + }); +}); + // create dummy values with a desired type const input = (): T => 123 as any