From 372f1d99827f788692c13162c0cac14dedf89481 Mon Sep 17 00:00:00 2001 From: Laurenz Honauer Date: Fri, 24 Mar 2023 13:11:23 +0100 Subject: [PATCH 1/3] feat: add a method to perform side effects --- src/result-async.ts | 13 ++++ src/result.ts | 17 +++++ tests/index.test.ts | 162 +++++++++++++++++++++++++++++--------------- 3 files changed, 137 insertions(+), 55 deletions(-) diff --git a/src/result-async.ts b/src/result-async.ts index 87285455..d01706e8 100644 --- a/src/result-async.ts +++ b/src/result-async.ts @@ -68,6 +68,19 @@ export class ResultAsync implements PromiseLike> { ) as CombineResultsWithAllErrorsArrayAsync } + tap(f: (t: T) => void | Promise): ResultAsync { + return new ResultAsync( + this._promise.then(async (res: Result) => { + if (res.isErr()) { + return new Err(res.error) + } + + await f(res.value) + return new Ok(res.value) + }), + ) + } + 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 59edf442..7915de33 100644 --- a/src/result.ts +++ b/src/result.ts @@ -79,6 +79,14 @@ interface IResult { */ isErr(): this is Err + /** + * Performs a side effect for the `Ok` variant of `Result`. + * + * @param f The function to apply an `OK` value + * @returns the result of applying `f` or an `Err` untouched + */ + tap(f: (t: T) => void): Result + /** * Maps a `Result` to `Result` * by applying a function to a contained `Ok` value, leaving an `Err` value @@ -201,6 +209,11 @@ export class Ok implements IResult { return !this.isOk() } + tap(f: (t: T) => void): Result { + f(this.value) + return ok(this.value) + } + map(f: (t: T) => A): Result { return ok(f(this.value)) } @@ -264,6 +277,10 @@ export class Err implements IResult { return !this.isOk() } + tap(_f: (t: T) => void): Result { + return err(this.error) + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars map(_f: (t: T) => A): Result { return err(this.error) diff --git a/tests/index.test.ts b/tests/index.test.ts index 7d2d056e..e09521ba 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -44,6 +44,19 @@ describe('Result.Ok', () => { expect(ok(42)).not.toEqual(ok(43)) }) + it('Taps into an Ok value', () => { + const okVal = ok(12) + + // value can be accessed, but is not changed + const sideEffect = jest.fn((number) => console.log(number)) + + const mapped = okVal.tap(sideEffect) + + expect(mapped.isOk()).toBe(true) + expect(mapped._unsafeUnwrap()).toBe(12) + expect(sideEffect).toHaveBeenCalledTimes(1) + }) + it('Maps over an Ok value', () => { const okVal = ok(12) const mapFn = jest.fn((number) => number.toString()) @@ -201,6 +214,18 @@ describe('Result.Err', () => { expect(err(42)).not.toEqual(err(43)) }) + it('Skips `tap`', () => { + const errVal = err('I am your father') + + const sideEffect = jest.fn((_value) => console.log('noooo')) + + const hopefullyNotMapped = errVal.tap(sideEffect) + + expect(hopefullyNotMapped.isErr()).toBe(true) + expect(sideEffect).not.toHaveBeenCalled() + expect(hopefullyNotMapped._unsafeUnwrapErr()).toEqual(errVal._unsafeUnwrapErr()) + }) + it('Skips `map`', () => { const errVal = err('I am your father') @@ -332,15 +357,15 @@ describe('Result.fromThrowable', () => { // Added for issue #300 -- the test here is not so much that expectations are met as that the test compiles. it('Accepts an inner function which takes arguments', () => { - const hello = (fname: string): string => `hello, ${fname}`; - const safeHello = Result.fromThrowable(hello); + const hello = (fname: string): string => `hello, ${fname}` + const safeHello = Result.fromThrowable(hello) - const result = hello('Dikembe'); - const safeResult = safeHello('Dikembe'); + const result = hello('Dikembe') + const safeResult = safeHello('Dikembe') - expect(safeResult).toBeInstanceOf(Ok); - expect(result).toEqual(safeResult._unsafeUnwrap()); - }); + expect(safeResult).toBeInstanceOf(Ok) + expect(result).toEqual(safeResult._unsafeUnwrap()) + }) it('Creates a function that returns an err when the inner function throws', () => { const thrower = (): string => { @@ -375,7 +400,7 @@ describe('Result.fromThrowable', () => { }) it('has a top level export', () => { - expect(fromThrowable).toBe(Result.fromThrowable) + expect(fromThrowable).toBe(Result.fromThrowable) }) }) @@ -406,15 +431,15 @@ describe('Utils', () => { }) it('Combines heterogeneous lists', () => { - type HeterogenousList = [ Result, Result, Result ] - - const heterogenousList: HeterogenousList = [ - ok('Yooooo'), - ok(123), - ok(true), + type HeterogenousList = [ + Result, + Result, + Result, ] - type ExpecteResult = Result<[ string, number, boolean ], string | number | boolean> + const heterogenousList: HeterogenousList = [ok('Yooooo'), ok(123), ok(true)] + + type ExpecteResult = Result<[string, number, boolean], string | number | boolean> const result: ExpecteResult = Result.combine(heterogenousList) @@ -422,21 +447,18 @@ describe('Utils', () => { }) it('Does not destructure / concatenate arrays', () => { - type HomogenousList = [ - Result, - Result, - ] + type HomogenousList = [Result, Result] - const homogenousList: HomogenousList = [ - ok(['hello', 'world']), - ok([1, 2, 3]) - ] + const homogenousList: HomogenousList = [ok(['hello', 'world']), ok([1, 2, 3])] - type ExpectedResult = Result<[ string[], number[] ], boolean | string> + type ExpectedResult = Result<[string[], number[]], boolean | string> const result: ExpectedResult = Result.combine(homogenousList) - expect(result._unsafeUnwrap()).toEqual([ [ 'hello', 'world' ], [ 1, 2, 3 ]]) + expect(result._unsafeUnwrap()).toEqual([ + ['hello', 'world'], + [1, 2, 3], + ]) }) }) @@ -445,7 +467,7 @@ describe('Utils', () => { const asyncResultList = [okAsync(123), okAsync(456), okAsync(789)] const resultAsync: ResultAsync = ResultAsync.combine(asyncResultList) - + expect(resultAsync).toBeInstanceOf(ResultAsync) const result = await ResultAsync.combine(asyncResultList) @@ -480,14 +502,14 @@ describe('Utils', () => { okAsync('Yooooo'), okAsync(123), okAsync(true), - okAsync([ 1, 2, 3]), + okAsync([1, 2, 3]), ] - type ExpecteResult = Result<[ string, number, boolean, number[] ], string | number | boolean> + type ExpecteResult = Result<[string, number, boolean, number[]], string | number | boolean> const result: ExpecteResult = await ResultAsync.combine(heterogenousList) - expect(result._unsafeUnwrap()).toEqual(['Yooooo', 123, true, [ 1, 2, 3 ]]) + expect(result._unsafeUnwrap()).toEqual(['Yooooo', 123, true, [1, 2, 3]]) }) }) }) @@ -517,15 +539,15 @@ describe('Utils', () => { }) it('Combines heterogeneous lists', () => { - type HeterogenousList = [ Result, Result, Result ] - - const heterogenousList: HeterogenousList = [ - ok('Yooooo'), - ok(123), - ok(true), + type HeterogenousList = [ + Result, + Result, + Result, ] - type ExpecteResult = Result<[ string, number, boolean ], (string | number | boolean)[]> + const heterogenousList: HeterogenousList = [ok('Yooooo'), ok(123), ok(true)] + + type ExpecteResult = Result<[string, number, boolean], (string | number | boolean)[]> const result: ExpecteResult = Result.combineWithAllErrors(heterogenousList) @@ -533,21 +555,18 @@ describe('Utils', () => { }) it('Does not destructure / concatenate arrays', () => { - type HomogenousList = [ - Result, - Result, - ] + type HomogenousList = [Result, Result] - const homogenousList: HomogenousList = [ - ok(['hello', 'world']), - ok([1, 2, 3]) - ] + const homogenousList: HomogenousList = [ok(['hello', 'world']), ok([1, 2, 3])] - type ExpectedResult = Result<[ string[], number[] ], (boolean | string)[]> + type ExpectedResult = Result<[string[], number[]], (boolean | string)[]> const result: ExpectedResult = Result.combineWithAllErrors(homogenousList) - expect(result._unsafeUnwrap()).toEqual([ [ 'hello', 'world' ], [ 1, 2, 3 ]]) + expect(result._unsafeUnwrap()).toEqual([ + ['hello', 'world'], + [1, 2, 3], + ]) }) }) describe('`ResultAsync.combineWithAllErrors`', () => { @@ -575,15 +594,15 @@ describe('Utils', () => { }) it('Combines heterogeneous lists', async () => { - type HeterogenousList = [ ResultAsync, ResultAsync, ResultAsync ] - - const heterogenousList: HeterogenousList = [ - okAsync('Yooooo'), - okAsync(123), - okAsync(true), + type HeterogenousList = [ + ResultAsync, + ResultAsync, + ResultAsync, ] - type ExpecteResult = Result<[ string, number, boolean ], [string, number, boolean]> + const heterogenousList: HeterogenousList = [okAsync('Yooooo'), okAsync(123), okAsync(true)] + + type ExpecteResult = Result<[string, number, boolean], [string, number, boolean]> const result: ExpecteResult = await ResultAsync.combineWithAllErrors(heterogenousList) @@ -676,6 +695,40 @@ describe('ResultAsync', () => { }) }) + describe('tap', () => { + it('Taps into an async value', async () => { + const asyncVal = okAsync(12) + + const sideEffect = jest.fn((number) => console.log(number)) + + const mapped = asyncVal.tap(sideEffect) + + expect(mapped).toBeInstanceOf(ResultAsync) + + const newVal = await mapped + + expect(newVal.isOk()).toBe(true) + expect(newVal._unsafeUnwrap()).toBe(12) + expect(sideEffect).toHaveBeenCalledTimes(1) + }) + + it('Skips an error when tapping into an asynchronous value', async () => { + const asyncErr = errAsync('Wrong format') + + const sideEffect = jest.fn((number) => console.log(number)) + + const notMapped = asyncErr.tap(sideEffect) + + expect(notMapped).toBeInstanceOf(ResultAsync) + + const newVal = await notMapped + + expect(newVal.isErr()).toBe(true) + expect(newVal._unsafeUnwrapErr()).toBe('Wrong format') + expect(sideEffect).toHaveBeenCalledTimes(0) + }) + }) + describe('map', () => { it('Maps a value using a synchronous function', async () => { const asyncVal = okAsync(12) @@ -709,7 +762,7 @@ describe('ResultAsync', () => { expect(mapAsyncFn).toHaveBeenCalledTimes(1) }) - it('Skips an error', async () => { + it('Skips an error when mapping an asynchronous value', async () => { const asyncErr = errAsync('Wrong format') const mapSyncFn = jest.fn((number) => number.toString()) @@ -831,7 +884,6 @@ describe('ResultAsync', () => { const okVal = okAsync(12) const errorCallback = jest.fn((_errVal) => errAsync('It is now a string')) - const result = await okVal.orElse(errorCallback) expect(result).toEqual(ok(12)) From 88dfb098759e3d0054bcd52d9650e2a5ff2b3dce Mon Sep 17 00:00:00 2001 From: Laurenz Honauer Date: Mon, 30 Oct 2023 17:05:06 +0100 Subject: [PATCH 2/3] Add documentation about .tap method --- README.md | 287 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 160 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index 0701f635..bb9f1681 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![supermacro](https://circleci.com/gh/supermacro/neverthrow.svg?style=svg)](https://app.circleci.com/pipelines/github/supermacro/neverthrow) - [![Package Size](https://badgen.net/bundlephobia/minzip/neverthrow)](https://bundlephobia.com/result?p=neverthrow) ## Description @@ -21,11 +20,11 @@ For asynchronous tasks, `neverthrow` offers a `ResultAsync` class which wraps a ## Table Of Contents -* [Installation](#installation) -* [Recommended: Use `eslint-plugin-neverthrow`](#recommended-use-eslint-plugin-neverthrow) -* [Top-Level API](#top-level-api) -* [API Documentation](#api-documentation) - + [Synchronous API (`Result`)](#synchronous-api-result) +- [Installation](#installation) +- [Recommended: Use `eslint-plugin-neverthrow`](#recommended-use-eslint-plugin-neverthrow) +- [Top-Level API](#top-level-api) +- [API Documentation](#api-documentation) + - [Synchronous API (`Result`)](#synchronous-api-result) - [`ok`](#ok) - [`err`](#err) - [`Result.isOk` (method)](#resultisok-method) @@ -41,7 +40,7 @@ For asynchronous tasks, `neverthrow` offers a `ResultAsync` class which wraps a - [`Result.fromThrowable` (static class method)](#resultfromthrowable-static-class-method) - [`Result.combine` (static class method)](#resultcombine-static-class-method) - [`Result.combineWithAllErrors` (static class method)](#resultcombinewithallerrors-static-class-method) - + [Asynchronous API (`ResultAsync`)](#asynchronous-api-resultasync) + - [Asynchronous API (`ResultAsync`)](#asynchronous-api-resultasync) - [`okAsync`](#okasync) - [`errAsync`](#errasync) - [`ResultAsync.fromPromise` (static class method)](#resultasyncfrompromise-static-class-method) @@ -54,12 +53,12 @@ For asynchronous tasks, `neverthrow` offers a `ResultAsync` class which wraps a - [`ResultAsync.match` (method)](#resultasyncmatch-method) - [`ResultAsync.combine` (static class method)](#resultasynccombine-static-class-method) - [`ResultAsync.combineWithAllErrors` (static class method)](#resultasynccombinewithallerrors-static-class-method) - + [Utilities](#utilities) + - [Utilities](#utilities) - [`fromThrowable`](#fromthrowable) - [`fromPromise`](#frompromise) - [`fromSafePromise`](#fromsafepromise) - + [Testing](#testing) -* [A note on the Package Name](#a-note-on-the-package-name) + - [Testing](#testing) +- [A note on the Package Name](#a-note-on-the-package-name) ## Installation @@ -85,8 +84,7 @@ With `eslint-plugin-neverthrow`, you are forced to consume the result in one of This ensures that you're explicitly handling the error of your `Result`. -This plugin is essentially a porting of Rust's [`must-use`](https://doc.rust-lang.org/std/result/#results-must-be-used) attribute. - +This plugin is essentially a porting of Rust's [`must-use`](https://doc.rust-lang.org/std/result/#results-must-be-used) attribute. ## Top-Level API @@ -150,7 +148,7 @@ myResult.isOk() // true myResult.isErr() // false ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -175,7 +173,7 @@ myResult.isOk() // false myResult.isErr() // true ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -189,7 +187,7 @@ Returns `true` if the result is an `Ok` variant isOk(): boolean { ... } ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -203,7 +201,7 @@ Returns `true` if the result is an `Err` variant isErr(): boolean { ... } ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -234,14 +232,12 @@ import { getLines } from 'imaginary-parser' const linesResult = getLines('1\n2\n3\n4\n') // this Result now has a Array inside it -const newResult = linesResult.map( - (arr: Array) => arr.map(parseInt) -) +const newResult = linesResult.map((arr: Array) => arr.map(parseInt)) newResult.isOk() // true ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -270,16 +266,16 @@ const rawHeaders = 'nonsensical gibberish and badly formatted stuff' const parseResult = parseHeaders(rawHeaders) -parseResult.mapErr(parseError => { +parseResult.mapErr((parseError) => { res.status(400).json({ - error: parseError + error: parseError, }) }) parseResult.isErr() // true ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -305,7 +301,7 @@ const multiply = (value: number): number => value * 2 const unwrapped: number = myResult.map(multiply).unwrapOr(10) ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -339,21 +335,13 @@ import { err, ok } from 'neverthrow' const sq = (n: number): Result => ok(n ** 2) -ok(2) - .andThen(sq) - .andThen(sq) // Ok(16) +ok(2).andThen(sq).andThen(sq) // Ok(16) -ok(2) - .andThen(sq) - .andThen(err) // Err(4) +ok(2).andThen(sq).andThen(err) // Err(4) -ok(2) - .andThen(err) - .andThen(sq) // Err(2) +ok(2).andThen(err).andThen(sq) // Err(2) -err(3) - .andThen(sq) - .andThen(sq) // Err(3) +err(3).andThen(sq).andThen(sq) // Err(3) ``` **Example 2: Flattening Nested Results** @@ -366,7 +354,7 @@ const nested = ok(ok(1234)) const notNested = nested.andThen((innerResult) => innerResult) ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -386,7 +374,7 @@ class Result { } ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -417,17 +405,17 @@ const dbQueryResult: Result = err(DatabaseError.NotFound) const updatedQueryResult = dbQueryResult.orElse((dbError) => dbError === DatabaseError.NotFound ? ok('User does not exist') // error recovery branch: ok() must be called with a value of type string - // - // - // err() can be called with a value of any new type that you want - // it could also be called with the same error value - // - // err(dbError) - : err(500) + : // + // + // err() can be called with a value of any new type that you want + // it could also be called with the same error value + // + // err(dbError) + err(500), ) ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -450,6 +438,7 @@ class Result { `match` is like chaining `map` and `mapErr`, with the distinction that with `match` both functions must have the same return type. The differences between `match` and chaining `map` and `mapErr` are that: + - with `match` both functions must have the same return type `A` - `match` unwraps the `Result` into an `A` (the match functions' return type) - This makes no difference if you are performing side effects only @@ -477,16 +466,17 @@ const attempt = computationThatMightFail() const answer = computationThatMightFail().match( (str) => str.toUpperCase(), - (err) => `Error: ${err}` + (err) => `Error: ${err}`, ) // `answer` is of type `string` ``` If you don't use the error parameter in your match callback then `match` is equivalent to chaining `map` with `unwrapOr`: + ```ts const answer = computationThatMightFail().match( (str) => str.toUpperCase(), - () => 'ComputationError' + () => 'ComputationError', ) // `answer` is of type `string` @@ -495,8 +485,7 @@ const answer = computationThatMightFail() .unwrapOr('ComputationError') ``` - -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -527,13 +516,13 @@ import { parseHeaders } from 'imaginary-http-parser' // parseHeaders(raw: string): Result const asyncRes = parseHeaders(rawHeader) - .map(headerKvMap => headerKvMap.Authorization) + .map((headerKvMap) => headerKvMap.Authorization) .asyncMap(findUserInDatabase) ``` Note that in the above example if `parseHeaders` returns an `Err` then `.map` and `.asyncMap` will not be invoked, and `asyncRes` variable will resolve to an `Err` when turned into a `Result` using `await` or `.then()`. - -[⬆️ Back to top](#toc) + +[⬆️ Back to top](#toc) --- @@ -558,15 +547,15 @@ map what is thrown to a known type. import { Result } from 'neverthrow' type ParseError = { message: string } -const toParseError = (): ParseError => ({ message: "Parse Error" }) +const toParseError = (): ParseError => ({ message: 'Parse Error' }) const safeJsonParse = Result.fromThrowable(JSON.parse, toParseError) // the function can now be used safely, if the function throws, the result will be an Err -const res = safeJsonParse("{"); +const res = safeJsonParse('{') ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -599,27 +588,25 @@ function combine => Result<[ T1, T2, T3, T4 ], E ``` Example: + ```typescript -const resultList: Result[] = - [ok(1), ok(2)] +const resultList: Result[] = [ok(1), ok(2)] -const combinedList: Result = - Result.combine(resultList) +const combinedList: Result = Result.combine(resultList) ``` Example with tuples: + ```typescript /** @example tuple(1, 2, 3) === [1, 2, 3] // with type [number, number, number] */ const tuple = (...args: T): T => args -const resultTuple: [Result, Result] = - tuple(ok('a'), ok('b')) +const resultTuple: [Result, Result] = tuple(ok('a'), ok('b')) -const combinedTuple: Result<[string, string], unknown> = - Result.combine(resultTuple) +const combinedTuple: Result<[string, string], unknown> = Result.combine(resultTuple) ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -647,19 +634,42 @@ function combineWithAllErrors => Result<[ T1, T2 Example usage: ```typescript -const resultList: Result[] = [ - ok(123), - err('boooom!'), - ok(456), - err('ahhhhh!'), -] +const resultList: Result[] = [ok(123), err('boooom!'), ok(456), err('ahhhhh!')] const result = Result.combineWithAllErrors(resultList) // result is Err(['boooom!', 'ahhhhh!']) ``` +[⬆️ Back to top](#toc) + +--- + +#### `Result.tap` (method) + +Executes a function if the result contains an `Ok` value and is ignored in case of an `Err` result. +The function does not change the value of the `Result`. This is useful for side effects, like logging. +**Signature:** + +```typescript +class Result { + tap(callback: (value: T) => void): Result { ... } +} +``` + +**Example**: + +```typescript +import { getUser } from 'database' + +const linesResult = getUser({ id: 1 }) + +// this Result now has a user inside it +linesResult.tap((user) => console.log(user)) +``` + +[⬆️ Back to top](#toc) --- @@ -688,7 +698,7 @@ myResult.isOk() // true myResult.isErr() // false ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -715,7 +725,7 @@ myResult.isOk() // false myResult.isErr() // true ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -725,7 +735,6 @@ Transforms a `PromiseLike` (that may throw) into a `ResultAsync`. The second argument handles the rejection case of the promise and maps the error from `unknown` into some type `E`. - **Signature:** ```typescript @@ -751,7 +760,7 @@ const res = ResultAsync.fromPromise(insertIntoDb(myUser), () => new Error('Datab // `res` has a type of ResultAsync ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -783,7 +792,7 @@ export const slowDown = (ms: number) => (value: T) => setTimeout(() => { resolve(value) }, ms) - }) + }), ) export const signupHandler = route((req, sessionManager) => @@ -792,12 +801,11 @@ export const signupHandler = route((req, sessionManager) => .andThen(slowDown(3000)) // slowdown by 3 seconds .andThen(sessionManager.createSession) .map(({ sessionToken, admin }) => AppData.init(admin, sessionToken)) - }) + }), ) ``` - -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -826,24 +834,23 @@ import { findUsersIn } from 'imaginary-database' // ^ assume findUsersIn has the following signature: // findUsersIn(country: string): ResultAsync, Error> -const usersInCanada = findUsersIn("Canada") +const usersInCanada = findUsersIn('Canada') // Let's assume we only need their names -const namesInCanada = usersInCanada.map((users: Array) => users.map(user => user.name)) +const namesInCanada = usersInCanada.map((users: Array) => users.map((user) => user.name)) // namesInCanada is of type ResultAsync, Error> // We can extract the Result using .then() or await namesInCanada.then((namesResult: Result, Error>) => { - if(namesResult.isErr()){ + if (namesResult.isErr()) { console.log("Couldn't get the users from the database", namesResult.error) - } - else{ - console.log("Users in Canada are named: " + namesResult.value.join(',')) + } else { + console.log('Users in Canada are named: ' + namesResult.value.join(',')) } }) ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -873,32 +880,31 @@ import { findUsersIn } from 'imaginary-database' // findUsersIn(country: string): ResultAsync, Error> // Let's say we need to low-level errors from findUsersIn to be more readable -const usersInCanada = findUsersIn("Canada").mapErr((error: Error) => { +const usersInCanada = findUsersIn('Canada').mapErr((error: Error) => { // The only error we want to pass to the user is "Unknown country" - if(error.message === "Unknown country"){ + if (error.message === 'Unknown country') { return error.message } // All other errors will be labelled as a system error - return "System error, please contact an administrator." + return 'System error, please contact an administrator.' }) // usersInCanada is of type ResultAsync, string> usersInCanada.then((usersResult: Result, string>) => { - if(usersResult.isErr()){ + if (usersResult.isErr()) { res.status(400).json({ - error: usersResult.error + error: usersResult.error, }) - } - else{ + } else { res.status(200).json({ - users: usersResult.value + users: usersResult.value, }) } }) ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -922,7 +928,7 @@ const unwrapped: number = await errAsync(0).unwrapOr(10) // unwrapped = 10 ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -953,7 +959,6 @@ class ResultAsync { **Example** ```typescript - import { validateUser } from 'imaginary-validator' import { insertUser } from 'imaginary-database' import { sendNotification } from 'imaginary-service' @@ -963,23 +968,20 @@ import { sendNotification } from 'imaginary-service' // insertUser(user): ResultAsync // sendNotification(user): ResultAsync -const resAsync = validateUser(user) - .andThen(insertUser) - .andThen(sendNotification) +const resAsync = validateUser(user).andThen(insertUser).andThen(sendNotification) // resAsync is a ResultAsync resAsync.then((res: Result) => { - if(res.isErr()){ - console.log("Oops, at least one step failed", res.error) - } - else{ - console.log("User has been validated, inserted and notified successfully.") + if (res.isErr()) { + console.log('Oops, at least one step failed', res.error) + } else { + console.log('User has been validated, inserted and notified successfully.') } }) ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -997,7 +999,7 @@ class ResultAsync { } ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -1021,7 +1023,6 @@ class ResultAsync { **Example:** ```typescript - import { validateUser } from 'imaginary-validator' import { insertUser } from 'imaginary-database' @@ -1031,16 +1032,16 @@ import { insertUser } from 'imaginary-database' // Handle both cases at the end of the chain using match const resultMessage = await validateUser(user) - .andThen(insertUser) - .match( - (user: User) => `User ${user.name} has been successfully created`, - (error: Error) => `User could not be created because ${error.message}` - ) + .andThen(insertUser) + .match( + (user: User) => `User ${user.name} has been successfully created`, + (error: Error) => `User could not be created because ${error.message}`, + ) // resultMessage is a string ``` -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -1071,26 +1072,28 @@ function combine => ResultAsync<[ T1, T2, T3, T4 ``` Example: + ```typescript -const resultList: ResultAsync[] = - [okAsync(1), okAsync(2)] +const resultList: ResultAsync[] = [okAsync(1), okAsync(2)] -const combinedList: ResultAsync = - ResultAsync.combine(resultList) +const combinedList: ResultAsync = ResultAsync.combine(resultList) ``` Example with tuples: + ```typescript /** @example tuple(1, 2, 3) === [1, 2, 3] // with type [number, number, number] */ const tuple = (...args: T): T => args -const resultTuple: [ResultAsync, ResultAsync] = - tuple(okAsync('a'), okAsync('b')) +const resultTuple: [ResultAsync, ResultAsync] = tuple( + okAsync('a'), + okAsync('b'), +) -const combinedTuple: ResultAsync<[string, string], unknown> = - ResultAsync.combine(resultTuple) +const combinedTuple: ResultAsync<[string, string], unknown> = ResultAsync.combine(resultTuple) ``` -[⬆️ Back to top](#toc) + +[⬆️ Back to top](#toc) --- @@ -1128,6 +1131,36 @@ const result = ResultAsync.combineWithAllErrors(resultList) // result is Err(['boooom!', 'ahhhhh!']) ``` +--- + +#### `ResultAsync.tap` (method) + +Executes a function if the ` ResultAsync` contains an `Ok` value and is ignored in case of an`Err`result. The applied function can be synchronous or asynchronous (returning a`Promise`) with no impact to the return type. + +The function does not change the value of the `Result`. This is useful for side effects, like logging. + +**Signature:** + +```typescript +class ResultAsync { + tap( + callback: (value: T) => void | Promise + ): ResultAsync { ... } +} +``` + +**Example**: + +```typescript +import { getUser } from 'database' + +const linesResult = getUser({ id: 1 }) + +// this Result now has a user inside it +linesResult.tap((user) => console.log(user)) +``` + +[⬆️ Back to top](#toc) --- @@ -1138,21 +1171,21 @@ const result = ResultAsync.combineWithAllErrors(resultList) Top level export of `Result.fromThrowable`. Please find documentation at [Result.fromThrowable](#resultfromthrowable-static-class-method) -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) #### `fromPromise` Top level export of `ResultAsync.fromPromise`. Please find documentation at [ResultAsync.fromPromise](#resultasyncfrompromise-static-class-method) -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) #### `fromSafePromise` Top level export of `ResultAsync.fromSafePromise`. Please find documentation at [ResultAsync.fromSafePromise](#resultasyncfromsafepromise-static-class-method) -[⬆️ Back to top](#toc) +[⬆️ Back to top](#toc) --- @@ -1177,7 +1210,7 @@ import { ok } from 'neverthrow' // ... -expect(callSomeFunctionThatReturnsAResult("with", "some", "args")).toEqual(ok(someExpectation)); +expect(callSomeFunctionThatReturnsAResult('with', 'some', 'args')).toEqual(ok(someExpectation)) ``` By default, the thrown value does not contain a stack trace. This is because stack trace generation [makes error messages in Jest harder to understand](https://github.com/supermacro/neverthrow/pull/215). If you want stack traces to be generated, call `_unsafeUnwrap` and / or `_unsafeUnwrapErr` with a config object: From 351fe4a990bf68a96d3e8950de111fa93403322b Mon Sep 17 00:00:00 2001 From: Laurenz Honauer Date: Mon, 30 Oct 2023 17:08:15 +0100 Subject: [PATCH 3/3] Add tests to check if values can be changed with .tap --- tests/index.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/index.test.ts b/tests/index.test.ts index e09521ba..241a05a0 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -57,6 +57,20 @@ describe('Result.Ok', () => { expect(sideEffect).toHaveBeenCalledTimes(1) }) + it('Cannot change a value with .tap', () => { + const original = { name: 'John' } + const okVal = ok(original) + + // value can be accessed, but is not changed + const sideEffect = jest.fn((_person) => ({ name: 'Alice' })) + + const mapped = okVal.tap(sideEffect) + + expect(mapped.isOk()).toBe(true) + expect(mapped._unsafeUnwrap()).toEqual(original) + expect(sideEffect).toHaveBeenCalledTimes(1) + }) + it('Maps over an Ok value', () => { const okVal = ok(12) const mapFn = jest.fn((number) => number.toString()) @@ -712,6 +726,24 @@ describe('ResultAsync', () => { expect(sideEffect).toHaveBeenCalledTimes(1) }) + it('Cannot change an async value with .tap', async () => { + const original = { name: 'John' } + const asyncVal = okAsync(original) + + const sideEffect = jest.fn((_person) => okAsync({ name: 'Alice' })) + + //@ts-ignore Ignoring this to run "dangerous code" + const mapped = asyncVal.tap(sideEffect) + + expect(mapped).toBeInstanceOf(ResultAsync) + + const newVal = await mapped + + expect(newVal.isOk()).toBe(true) + expect(newVal._unsafeUnwrap()).toEqual(original) + expect(sideEffect).toHaveBeenCalledTimes(1) + }) + it('Skips an error when tapping into an asynchronous value', async () => { const asyncErr = errAsync('Wrong format')