From 5551d669a2b51e0f28231ea160803031b3afc582 Mon Sep 17 00:00:00 2001 From: Lonli-Lokli Date: Tue, 24 Dec 2024 14:22:19 +0000 Subject: [PATCH] feat: add filter/zip/taprecover methods --- README.md | 366 ++++++++++++++++--- packages/ts-result/package.json | 2 +- packages/ts-result/src/lib/ts-result.spec.ts | 175 +++++++++ packages/ts-result/src/lib/ts-result.ts | 67 +++- 4 files changed, 562 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 4b8366e..211a4ae 100644 --- a/README.md +++ b/README.md @@ -71,28 +71,60 @@ const user = getUser(1).map(({ email }) => email); - [`Result#toNullable`](#resulttonullable) - [`Result#toUndefined`](#resulttoundefined) - [`Result#unwrap`](#resultunwrap) +- [`Result#unwrapOr`](#resultunwrapor) +- [`Result#unwrapOrElse`](#resultunwraporelse) - [`Result#fold`](#resultfold) +- [`Result#filter`](#resultfilter) +- [`Result#filtermap`](#resultfiltermap) +- [`Result#tap`](#resulttap) +- [`Result#tapFailure`](#resulttapfailure) +- [`Result#recover`](#resultrecover) +- [`Result#recoverWith`](#resultrecoverwith) +- [`Result#zip`](#resultzip) +- [`Result#zipWith`](#resultzipwith) +- [`Result#biMap`](#resultbimap) - [`Helpers`](#helpers) #### `chain` ```typescript -function chain(fn: (v: S) => Promise>): (m: Result) => Promise>; +function chain(fn: (val: S) => Result): Result; ``` -- `fn: (v: S) => Promise>` - function which should be applied asynchronously to `Result` value -- Returns function with `Result` argument and promisied `Result` with new error or mapped by `fn` value (could be used inside `Promise#then` function). +- Returns a new Result by applying `fn` to the Success value of this Result +- State handling priority: + 1. If this Result is `Initial`, returns `Initial` + 2. If the next Result (returned by `fn`) is `Initial`, returns `Initial` + 3. If this Result is `Pending` or the next Result is `Pending`, returns `Pending` + 4. If this Result is `Failure`, returns `Failure` with current value + 5. If the next Result is `Failure`, returns `Failure` with next value + 6. Otherwise returns the next Result Example: ```typescript -const getValue = async () => success(1); -// Result -const result = await getValue() - .then(chain(async v => success(v * 2))) - .then(chain(async g => failure(new TypeError("Unexpected")))); +const v1 = success(2); +const v2 = failure(new Error()); +const v3 = initial; + +// Result.Success with value "2" +const newVal1 = v1.chain(a => success(a.toString())); + +// Result.Failure with value new TypeError() +const newVal2 = v1.chain(a => failure(new TypeError())); + +// Result.Failure with value new Error() +const newVal3 = v2.chain(a => success(a.toString())); + +// Result.Failure with value new Error() +const newVal4 = v2.chain(a => failure(new TypeError())); + +// Result.Initial with no value +const newVal5 = v3.chain(a => failure(new TypeError())); ``` +The chain method is particularly useful when you need to sequence operations that might fail or be in different states. It handles all possible state combinations according to the priority rules above. + #### `merge` Alias for [`mergeInOne`](#mergeinone) @@ -273,7 +305,7 @@ fromTry(() => { Returns promise of `Success` if the provided promise fulfilled or `Failure` with the error value if the provided promise rejected. ```typescript -function fromPromise(promise: Promise): Promise>; +function fromPromise(promise: Promise): Promise>; ``` ```typescript @@ -328,7 +360,7 @@ fromNullable(null as Nullable); // Result.Initial #### `isResult` ```typescript -function isResult(value: unknown | Result): value is Result; +function isResult(value: unknown | Result): value is Result; ``` - Returns `boolean` if given `value` is instance of Result constructor. @@ -450,10 +482,10 @@ v2.or(v5).or(v3); // v3 will be returned #### `Result#join` ```typescript -function join(this: Result>): Result; +function join(this: Result>): Result; ``` -- `this: Result>` - `Result` instance which contains other `Result` instance as `Success` value. +- `this: Result>` - `Result` instance which contains other `Result` instance as `Success` value. - Returns unwrapped `Result` - if current `Result` has `Success` state and inner `Result` has `Success` state then returns inner `Result` `Success`, if inner `Result` has `Failure` state then return inner `Result` `Failure` otherwise outer `Result` `Failure`. Example: @@ -547,8 +579,8 @@ const newVal2 = v2.asyncMap(a => Promise.resolve(a.toString())); ##### `Result#apply` ```typescript -function apply(this: Result B>, arg: Result): Result; -function apply(this: Result, fn: Result B>): Result; +function apply(this: Result B>, arg: Result): Result; +function apply(this: Result, fn: Result B>): Result; ``` - `this | fn` - function wrapped by Result, which should be applied to value `arg` @@ -574,15 +606,12 @@ const newVal4 = fn2.apply(v2); // Result.Left with value new Erro Async variant of [`Result#apply`](#resultapply) ```typescript -asyncApply( - this: Result | A) => Promise>, - arg: Result>): Promise>; -asyncApply( - this: Result>, - fn: Result | A) => B>>): Promise>; -asyncApply( - this: Result> | Result | A) => Promise>, - argOrFn: Result> | Result | A) => Promise>): Promise> +asyncApply( + this: Result | S) => Promise>, + arg: Result>): Promise>; +asyncApply( + this: Result>, + fn: Result | S) => B>>): Promise>; ``` - `this | fn` - function wrapped by Result, which should be applied to value `arg` @@ -606,10 +635,17 @@ const newVal4 = fn2.asyncApply(v2); // Promise.Left> with #### `Result#chain` ```typescript -function chain(fn: (val: S) => Either): Either; +function chain(fn: (val: S) => Result): Result; ``` -- Returns mapped by `fn` function value wrapped by `Result` if `Result` is `Success` and returned by `fn` value is `Success` too otherwise `Result` in other state, `Initial` pwns `Pending` and `Failure`. +- Returns a new Result by applying `fn` to the Success value of this Result +- State handling priority: + 1. If this Result is `Initial`, returns `Initial` + 2. If the next Result (returned by `fn`) is `Initial`, returns `Initial` + 3. If this Result is `Pending` or the next Result is `Pending`, returns `Pending` + 4. If this Result is `Failure`, returns `Failure` with current value + 5. If the next Result is `Failure`, returns `Failure` with next value + 6. Otherwise returns the next Result Example: @@ -620,16 +656,22 @@ const v3 = initial; // Result.Success with value "2" const newVal1 = v1.chain(a => success(a.toString())); + // Result.Failure with value new TypeError() const newVal2 = v1.chain(a => failure(new TypeError())); + // Result.Failure with value new Error() const newVal3 = v2.chain(a => success(a.toString())); + // Result.Failure with value new Error() const newVal4 = v2.chain(a => failure(new TypeError())); + // Result.Initial with no value const newVal5 = v3.chain(a => failure(new TypeError())); ``` +The chain method is particularly useful when you need to sequence operations that might fail or be in different states. It handles all possible state combinations according to the priority rules above. + ##### `Result#asyncChain` ```typescript @@ -732,13 +774,53 @@ initial.unwrap(); // throws default (Error) pending.unwrap({ failure: () => new Error('Custom')}); // throws custom (Error) ``` +#### `Result#unwrapOr` + +```typescript +function unwrapOr(s: S): S; +``` + +- Returns the success value if Result is Success, otherwise returns the provided default value. + +Example: + +```typescript +const v1 = success(2); +const v2 = failure(new Error()); +v1.unwrapOr(3); // returns 2 +v2.unwrapOr(3); // returns 3 +``` +#### `Result#unwrapOrElse` + +```typescript +function unwrapOrElse(f: (l: F) => S): S; +``` + +- Returns the success value if Result is Success, otherwise returns the result of calling the provided function with the failure value. + + +Example: + +```typescript +const v1 = success(2); +const v2 = failure(3); +v1.unwrapOrElse(x => x * 2); // returns 2 +v2.unwrapOrElse(x => x *2); // returns 6 +``` + #### `Result#fold` ```typescript -function fold(onInitial: () => D, onPending: () => D, onFailure: (failure: F) => D, onSuccess: (success: S) => D): S; +function fold(onInitial: () => D, onPending: () => D, onFailure: (failure: F) => D, onSuccess: (success: S) => D): D; ``` -- Extracts value from `Result` and converts it to `D` based on the factory +- Transforms the Result value into type D by providing handlers for all possible states +- Parameters: + - `onInitial: () => D` - Handler for Initial state + - `onPending: () => D` - Handler for Pending state + - `onFailure: (failure: F) => D` - Handler for Failure state, receives the failure value + - `onSuccess: (success: S) => D` - Handler for Success state, receives the success value +- Returns the result of calling the appropriate handler based on the Result state Example: @@ -747,36 +829,228 @@ const onInitial = () => "it's initial" const onPending = () => "it's pending" const onFailure = (err) => "it's failure" const onSuccess = (data) => `${data + 1}` -const f = fold(onInitial, onPending, onFailure, onSuccess) -f(initial()) // "it's initial" -f(pending()) // "it's pending" -f(failure(new Error('error text'))) // "it's failure" -f(success(21)) // '22' +const v1 = initial; +const v2 = pending; +const v3 = failure('error'); +const v4 = success(21); +v1.fold(onInitial, onPending, onFailure, onSuccess) // "it's initial" +v2.fold(onInitial, onPending, onFailure, onSuccess) // "it's pending" +v3.fold(onInitial, onPending, onFailure, onSuccess) // "it's failure" +v4.fold(onInitial, onPending, onFailure, onSuccess) // "22" ``` -#### Helpers +The fold method is particularly useful when you need to handle all possible states of a Result and transform them into a single type. This pattern is common when you need to: +- Display different UI states +- Convert Result states into a common format +- Handle all possible outcomes in a type-safe way + +#### `Result#filter` ```typescript -// Value from Result instance -const { value } = success(2); // number | Error | undefined -const { value } = success(2); // number | undefined -const { value } = failure(new Error()); // number | Error | undefined -const { value } = failure(new Error()); // Error | undefined +function filter(predicate: (value: S) => boolean): Result; +``` + +Validates a Success value using a predicate. If the predicate returns false, converts the Success to a Failure using the success value. + +```typescript +// Age validation +const age = success(15); +const isAdult = age.filter(age => age >= 18); +// Result.Failure with value 15 + +// Chaining validations +const validAge = success(25) + .filter(age => age >= 0) // minimum age + .filter(age => age <= 120); // maximum age +// Result.Success with value 25 +``` + +#### `Result#filterMap` + +```typescript +function filterMap(f: (value: S) => Result): Result; +``` + +Combines filtering and mapping in one operation. Useful for transformations that might fail. + +```typescript +const parseIfPositive = (n: number) => + n > 0 ? success(n.toString()) : failure(n); + +success(5).filterMap(parseIfPositive) +// Result.Success with value "5" + +success(-1).filterMap(parseIfPositive) +// Result.Failure with value -1 +``` + +```typescript +failure('error').filter(x => true) // stays Failure +initial.filter(x => true) // stays Initial +pending.filter(x => true) // stays Pending + +failure('error').filterMap(fn) // stays Failure +initial.filterMap(fn) // stays Initial +pending.filterMap(fn) // stays Pending ``` +#### `Result#tap` + +```typescript +function tap(f: (value: S) => void): Result; +``` + +Executes a side effect function if the Result is Success, then returns the original Result unchanged. +Useful for logging, debugging, or other side effects without modifying the Result chain. + +```typescript +success(5) + .tap(x => console.log('Value:', x)) // logs "Value: 5" + .map(x => x * 2); // Result.Success(10) + +failure('error') + .tap(x => console.log('Value:', x)) // nothing logged + .map(x => x * 2); // Result.Failure('error') +``` + +#### `Result#tapFailure` + +```typescript +function tapFailure(f: (value: F) => void): Result; +``` + +Executes a side effect function if the Result is Failure, then returns the original Result unchanged. +Useful for error logging or debugging without modifying the Result chain. + +```typescript +success(5) + .tapFailure(e => console.error(e)) // nothing logged + .map(x => x * 2); // Result.Success(10) + +failure(new Error('oops')) + .tapFailure(e => console.error(e)) // logs Error: oops + .map(x => x * 2); // Result.Failure(Error: oops) +``` + + +#### `Result#recover` + +```typescript +function recover(value: S): Result; +``` + +Recovers from a Failure state by providing a default success value. + +```typescript +const v1 = failure('error'); +const v2 = success(5); + +v1.recover(42); // Result.Success with value 42 +v2.recover(42); // Result.Success with value 5 (unchanged) +``` + +#### `Result#recoverWith` + +```typescript +function recoverWith(f: (error: F) => Result): Result; +``` + +Recovers from a Failure state by applying a function that returns a new Result. +Useful for handling specific error cases differently or transforming errors. + +```typescript +const handler = (error: string): Result => + error === 'known' ? success(42) : failure(new Error('still failed')); + +failure('known').recoverWith(handler); // Result.Success(42) +failure('unknown').recoverWith(handler); // Result.Failure(Error: still failed) +success(5).recoverWith(handler); // Result.Success(5) + +// Initial and Pending states pass through +initial.recoverWith(handler); // Result.Initial +pending.recoverWith(handler); // Result.Pending +``` + + +#### `Result#zip` + +```typescript +function zip(other: Result): Result; +``` + +Combines two Results into a Result containing a tuple of their success values. +Returns Failure if either Result is a Failure. + +```typescript +const num = success(2); +const str = success('test'); + +num.zip(str) // Result.Success([2, 'test']) +num.zip(failure('error')) // Result.Failure('error') +``` + +#### `Result#zipWith` + +```typescript +function zipWith( + other: Result, + f: (a: S, b: S2) => R +): Result; +``` + +Combines two Results using a function. Returns Failure if either Result is a Failure. + +```typescript +const num1 = success(2); +const num2 = success(3); + +num1.zipWith(num2, (a, b) => a + b) // Result.Success(5) +num1.zipWith(failure('error'), (a, b) => a + b) // Result.Failure('error') +``` + +#### `Result#bimap` + ```typescript -success(2).unwrap() // number -failure(new TypeError()).unwrap() // throws -failure(2).unwrap() // throws (don't do this) +function bimap( + failureMap: (f: F) => NF, + successMap: (s: S) => NS +): Result; +``` + +Maps both the Failure and Success values of a Result simultaneously. Useful for transforming both possible outcomes in one operation. + +```typescript +const result = success(42); + +// Transform both success and failure values +const transformed = result.bimap( + (error: string) => new Error(error), // transform failure + (value: number) => value.toString() // transform success +); +// Result.Success('42') -failure(2).unwrapOr(3) // returns 3 -success(2).unwrapOr(3) // returns 2 +const failed = failure('oops'); +const transformedFailure = failed.bimap( + (error: string) => new Error(error), + (value: number) => value.toString() +); +// Result.Failure(Error: oops) -failure(2).unwrapOrElse(num => num * 2) // returns 4 -success(2).unwrapOrElse(num => num * 2) // returns 2 +// Initial and Pending states pass through unchanged +initial.bimap(f, s) // Result.Initial +pending.bimap(f, s) // Result.Pending +``` + +#### Helpers +```typescript +// Value from Result instance +const { value } = success(2); // number | Error | undefined +const { value } = success(2); // number | undefined +const { value } = failure(new Error()); // number | Error | undefined +const { value } = failure(new Error()); // Error | undefined ``` ## Development @@ -784,4 +1058,4 @@ success(2).unwrapOrElse(num => num * 2) // returns 2 ## License -MIT (c) +MIT (c) \ No newline at end of file diff --git a/packages/ts-result/package.json b/packages/ts-result/package.json index ada3c21..01f81ce 100644 --- a/packages/ts-result/package.json +++ b/packages/ts-result/package.json @@ -1,6 +1,6 @@ { "name": "@lonli-lokli/ts-result", - "version": "2.4.0", + "version": "2.5.0", "private": false, "sideEffects": false, "main": "dist/index.js", diff --git a/packages/ts-result/src/lib/ts-result.spec.ts b/packages/ts-result/src/lib/ts-result.spec.ts index 1117c62..45dc07a 100644 --- a/packages/ts-result/src/lib/ts-result.spec.ts +++ b/packages/ts-result/src/lib/ts-result.spec.ts @@ -555,3 +555,178 @@ function result(): fc.Arbitrary> { }) .noBias(); } + +describe('filter/filterMap', () => { + test('filter', () => { + // Basic filtering + expect(success(5).filter(x => x > 3).isSuccess()).toBe(true); + expect(success(2).filter(x => x > 3).isFailure()).toBe(true); + + // Non-success states pass through + expect(failure('error').filter(x => x > 3).isFailure()).toBe(true); + expect(initial.filter(x => x > 3).isInitial()).toBe(true); + expect(pending.filter(x => x > 3).isPending()).toBe(true); + + // Failure contains the rejected value + const result = success(2).filter(x => x > 3); + expect(result.isFailure() && result.value).toBe(2); + }); + + test('filterMap', () => { + const parseIfPositive = (n: number) => + n > 0 ? success(n.toString()) : failure(n); + + // Success case + expect(success(5).filterMap(parseIfPositive).unwrapOr('')).toBe('5'); + + // Failure case + expect(success(-1).filterMap(parseIfPositive).isFailure()).toBe(true); + + // Non-success states pass through + expect(failure('error').filterMap(parseIfPositive).isFailure()).toBe(true); + expect(initial.filterMap(parseIfPositive).isInitial()).toBe(true); + expect(pending.filterMap(parseIfPositive).isPending()).toBe(true); + }); +}); + +describe('tap/tapFailure', () => { + test('tap', () => { + let sideEffect = 0; + const tap = (x: number) => { sideEffect = x; }; + + // Success executes side effect + success(5).tap(tap); + expect(sideEffect).toBe(5); + + // Other states don't execute side effect + sideEffect = 0; + failure(3).tap(tap); + expect(sideEffect).toBe(0); + + initial.tap(tap); + expect(sideEffect).toBe(0); + + pending.tap(tap); + expect(sideEffect).toBe(0); + }); + + test('tapFailure', () => { + let sideEffect = 0; + const tap = (x: number) => { sideEffect = x; }; + + // Failure executes side effect + failure(5).tapFailure(tap); + expect(sideEffect).toBe(5); + + // Other states don't execute side effect + sideEffect = 0; + success(3).tapFailure(tap); + expect(sideEffect).toBe(0); + + initial.tapFailure(tap); + expect(sideEffect).toBe(0); + + pending.tapFailure(tap); + expect(sideEffect).toBe(0); + }); +}); + +describe('recover/recoverWith', () => { + test('recover', () => { + // Recovers from failure + expect(failure('error').recover(42).unwrapOr(0)).toBe(42); + + // Doesn't affect success + expect(success(5).recover(42).unwrapOr(0)).toBe(5); + + // Recovers from initial/pending + expect(initial.recover(42).unwrapOr(0)).toBe(42); + expect(pending.recover(42).unwrapOr(0)).toBe(42); + }); + + test('recoverWith', () => { + const handler = (error: string): Result => + error === 'known' ? success(42) : failure('still failed'); + + // Recovers from known error + expect(failure('known').recoverWith(handler).unwrapOr(0)).toBe(42); + + // Propagates new failure for unknown error + expect(failure('unknown').recoverWith(handler).isFailure()).toBe(true); + + // Doesn't affect success + expect(success(5).recoverWith(handler).unwrapOr(0)).toBe(5); + + // Doesn't affect initial/pending + expect(initial.recoverWith(handler).isInitial()).toBe(true); + expect(pending.recoverWith(handler).isPending()).toBe(true); + }); +}); + +describe('zip/zipWith', () => { + test('zip', () => { + const v1 = success(2); + const v2 = success('test'); + const v3 = failure('error'); + + // Success cases + expect(v1.zip(v2).unwrapOr([0, ''])).toEqual([2, 'test']); + + // Failure cases + expect(v1.zip(v3).isFailure()).toBe(true); + expect(v3.zip(v2).isFailure()).toBe(true); + + // Initial/Pending cases + expect(v1.zip(initial).isInitial()).toBe(true); + expect(v1.zip(pending).isPending()).toBe(true); + expect(initial.zip(v1).isInitial()).toBe(true); + expect(pending.zip(v1).isPending()).toBe(true); + }); + + test('zipWith', () => { + const v1 = success(2); + const v2 = success(3); + const v3 = failure('error'); + + const add = (a: number, b: number) => a + b; + + // Success cases + expect(v1.zipWith(v2, add).unwrapOr(0)).toBe(5); + + // Failure cases + expect(v1.zipWith(v3, add).isFailure()).toBe(true); + expect(v3.zipWith(v2, add).isFailure()).toBe(true); + + // Initial/Pending cases + expect(v1.zipWith(initial, add).isInitial()).toBe(true); + expect(v1.zipWith(pending, add).isPending()).toBe(true); + expect(initial.zipWith(v1, add).isInitial()).toBe(true); + expect(pending.zipWith(v1, add).isPending()).toBe(true); + }); +}); + +describe('bifunctor', () => { + test('bimap', () => { + const toString = (x: number) => x.toString(); + const toError = (s: string) => new Error(s); + + // Success case + const v1 = success(42); + const r1 = v1.bimap(toError, toString); + expect(r1.isSuccess()).toBe(true); + expect(r1.unwrapOr('')).toBe('42'); + + // Failure case + const v2 = failure('error'); + const r2 = v2.bimap(toError, toString); + expect(r2.isFailure()).toBe(true); + if (r2.isFailure()) { + expect(r2.value).toBeInstanceOf(Error); + expect(r2.value.message).toBe('error'); + } + + // Initial/Pending pass through + expect(initial.bimap(toError, toString).isInitial()).toBe(true); + expect(pending.bimap(toError, toString).isPending()).toBe(true); + }); +}); diff --git a/packages/ts-result/src/lib/ts-result.ts b/packages/ts-result/src/lib/ts-result.ts index e13e651..e36a091 100644 --- a/packages/ts-result/src/lib/ts-result.ts +++ b/packages/ts-result/src/lib/ts-result.ts @@ -182,7 +182,7 @@ class ResultConstructor F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10, [S1, S2, S3, S4, S5, S6, S7, S8, S9, S10] >; - static mergeInOne(result: Array>): Result; + static mergeInOne(result: Array>): Result; static mergeInOne(results: Array>) { return results.reduce( (acc: Result>, curr) => @@ -681,6 +681,65 @@ class ResultConstructor get [Symbol.toStringTag]() { return 'Result'; } + + filter(predicate: (value: S) => boolean): Result { + if (!this.isSuccess()) { + return this as Result; + } + return predicate(this.value) ? this as Result : ResultConstructor.failure(this.value); + } + + filterMap(f: (value: S) => Result): Result { + return this.chain(f); + } + + tap(f: (value: S) => void): Result { + if (this.isSuccess()) { + f(this.value); + } + return this as Result; + } + + tapFailure(f: (value: F) => void): Result { + if (this.isFailure()) { + f(this.value); + } + return this as Result; + } + + recover(value: NS): Result { + return this.isSuccess() ? this as Result : success(value); + } + + recoverWith(f: (error: F) => Result): Result { + return this.isFailure() + ? f(this.value) + : (this as unknown as Result); + } + + zip(other: Result): Result { + return this.chain((a) => other.map((b) => [a, b] as [S, S2])); + } + + zipWith( + other: Result, + f: (a: S, b: S2) => R + ): Result { + return this.chain((a) => other.map((b) => f(a, b))); + } + + bimap( + failureMap: (f: F) => NF, + successMap: (s: S) => NS + ): Result { + if (this.isSuccess()) { + return success(successMap(this.value)); + } + if (this.isFailure()) { + return failure(failureMap(this.value)); + } + return this as unknown as Result; + } } export type Result = @@ -730,3 +789,9 @@ export const isFailure = ( value: unknown | Result ): value is ResultConstructor => isResult(value) && value.isFailure(); + +// Helper type to extract the Success type from a Result +export type Success = T extends Result ? S : never; + +// Helper type to extract the Failure type from a Result +export type Failure = T extends Result ? F : never;