diff --git a/README.md b/README.md index 898a07b..625bcf9 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ This is a set of implementations of monads in TypeScript with OOP perspective. * [Mapping over an Option](#mapping-over-an-option) * [Using `flatMap`](#using-flatmap) * [Using `map`](#using-map) + * [Using Railway Pattern Methods](#using-railway-pattern-methods-1) * [Running side effects](#running-side-effects-2) * [Folding an Option](#folding-an-option) * [Checking if an Option is Some or None](#checking-if-an-option-is-some-or-none) @@ -42,6 +43,7 @@ This is a set of implementations of monads in TypeScript with OOP perspective. * [Usage](#usage-2) * [Using `map`](#using-map-1) * [Using `flatMap`](#using-flatmap-1) + * [Using Railway Pattern Methods](#using-railway-pattern-methods-2) * [Running side effects](#running-side-effects-3) * [Retrieving the value](#retrieving-the-value) * [Folding a Try](#folding-a-try) @@ -136,7 +138,8 @@ const left = Either.left('Error').mapLeft(err => `New ${err}`); // Left('New Err #### Using Railway Pattern Methods -You can use `andThen` and `orElse` methods which follow the Railway-oriented programming pattern. These methods are semantically equivalent to `flatMap` and `flatMapLeft` but offer more readable syntax for error handling flows. +You can use `andThen` and `orElse` methods which follow the Railway-oriented programming pattern. These methods are +semantically equivalent to `flatMap` and `flatMapLeft` but offer more readable syntax for error handling flows. ```typescript import { Either } from '@leanmind/monads'; @@ -160,6 +163,85 @@ const result = Either.right(42) .orElse(err => Either.left(`Error: ${err}`)); // Right(43) ``` +You can use `combineWith` to combine multiple Either instances into one that contains a tuple of their values. This is +useful for collecting multiple validations or operations that could fail. + +```typescript +import { Either } from '@leanmind/monads'; + +class Name { + private constructor(private value: string) { + } + static of(value: string): Either { + return value.length >= 2 + ? Either.right(new Name(value)) + : Either.left('Name must be at least 2 characters long'); + } +} + +class Email { + private constructor(private value: string) { + } + static of(value: string): Either { + return value.includes('@') + ? Either.right(new Email(value)) + : Either.left('Email must contain @ symbol'); + } +} + +class Age { + private constructor(private value: number) { + } + static of(value: number): Either { + return value >= 18 + ? Either.right(new Age(value)) + : Either.left('Age must be at least 18'); + } +} + +class Address { + private constructor(private value: string) { + } + static of(value: string): Either { + return value.length > 5 + ? Either.right(new Address(value)) + : Either.left('Address must be longer than 5 characters'); + } +} + +// Class that requires all validated fields +class Account { + constructor( + public name: Name, + public email: Email, + public age: Age, + public address: Address, + ) {} +} + +// Combine all validations and create account if all are successful +const maybeAccount = Name.of('John') + .combineWith<[Email, Age, Address]>([ + Email.of('john@mail.com'), + Age.of(37), + Address.of('Main St., 123') + ]) + .map(([name, email, age, address]) => new Account(name, email, age, address)); + +// Result: Right(Account{...}) + +// If any validation fails, the result will be Left with the first error +const failedAge = Name.of('John') + .combineWith<[Email, Age, Address]>([ + Email.of('john@mail.com'), + Age.of(16), // This will fail + Address.of('Main St., 123'), + ]) + .map(([name, email, age, address]) => new Account(name, email, age, address)); + +// Result: Left('Age must be at least 18') +``` + #### Recovering from a Left value You can use the `recover` method to recover from a `Left` value and transform it into a `Right`. @@ -353,7 +435,8 @@ const asyncMapped = await AsyncEither.fromSync(Either.right(42)) ##### Using Railway Pattern Methods with AsyncEither -Similar to synchronous Either, AsyncEither also supports Railway-oriented programming with `andThen` and `orElse` methods: +Similar to synchronous Either, AsyncEither also supports Railway-oriented programming with `andThen` and `orElse` +methods: ```typescript import { AsyncEither, Either } from '@leanmind/monads'; @@ -380,25 +463,25 @@ async function fetchUserData(userId: string) { fetch(`https://api.example.com/users/${userId}`), error => `Failed to fetch user: ${error.message}` ) - .andThen(response => { - if (!response.ok) { - return AsyncEither.fromSync(Either.left(`HTTP error: ${response.status}`)); - } - return AsyncEither.fromPromise( - response.json(), - error => `Failed to parse response: ${error.message}` - ); - }) - .andThen(user => { - if (!user.id) { - return AsyncEither.fromSync(Either.left('Invalid user data')); - } - return AsyncEither.fromSync(Either.right(user)); - }) - .orElse(error => { - console.error(`API error: ${error}`); - return AsyncEither.fromSync(Either.left(`Friendly error: Something went wrong`)); - }); + .andThen(response => { + if (!response.ok) { + return AsyncEither.fromSync(Either.left(`HTTP error: ${response.status}`)); + } + return AsyncEither.fromPromise( + response.json(), + error => `Failed to parse response: ${error.message}` + ); + }) + .andThen(user => { + if (!user.id) { + return AsyncEither.fromSync(Either.left('Invalid user data')); + } + return AsyncEither.fromSync(Either.right(user)); + }) + .orElse(error => { + console.error(`API error: ${error}`); + return AsyncEither.fromSync(Either.left(`Friendly error: Something went wrong`)); + }); } ``` @@ -571,6 +654,74 @@ const some = Option.of(42).map(x => x + 1); // Some(43) const none = Option.of(null).map(x => x + 1); // None ``` +#### Using Railway Pattern Methods + +Option also supports Railway-oriented programming with `andThen` and `orElse` methods, which provide a clean way to +chain operations: + +```typescript +import { Option } from '@leanmind/monads'; + +// Using andThen with Option +const result = Option.of(42) + .andThen(x => Option.of(x + 1)); // Some(43) + +// Using orElse to provide an alternative for None +const none = Option.of(null) + .orElse(() => Option.of(42)); // Some(42) + +// Chaining operations +const validationResult = Option.of('test@example.com') + .andThen(email => { + if (email.includes('@')) { + return Option.of(email); + } + return Option.none(); + }) + .orElse(() => Option.of('default@example.com')); +``` + +You can use `combineWith` to combine multiple Option instances into one that contains a tuple of their values. This is +useful when you need all values to be present to proceed. + +```typescript +import { Option } from '@leanmind/monads'; + +// User profile information that may be incomplete +const username = Option.of('johndoe'); +const email = Option.of('john@example.com'); +const age = Option.of(30); +const address = Option.of('123 Main St'); + +// Combine all fields to create a complete profile +const completeProfile = username + .combineWith<[string, number, string]>([email, age, address]) + .map(([name, mail, years, addr]) => ({ + username: name, + email: mail, + age: years, + address: addr + })); + +// If all fields are present: Some({ username: 'johndoe', email: 'john@example.com', age: 30, address: '123 Main St' }) + +// If any field is missing, the result will be None +const incompleteProfile = username + .combineWith<[string, number, string]>([ + email, + Option.of(undefined), // Missing age + address + ]) + .map(([name, mail, years, addr]) => ({ + username: name, + email: mail, + age: years, + address: addr + })); + +// Result: None +``` + #### Running side effects You can use the `onSome` method to run side effects on the value inside a `Some`. @@ -673,6 +824,73 @@ import { Try } from '@leanmind/monads'; const success = Try.success(42).flatMap(x => Try.success(x + 1)); // Success(43) ``` +#### Using Railway Pattern Methods + +Try also supports Railway-oriented programming with `andThen` and `orElse` methods, which provide a clean way to handle +success and error cases: + +```typescript +import { Try } from '@leanmind/monads'; + +// Using andThen to chain successful operations +const result = Try.execute(() => JSON.parse('{"key": "value"}')) + .andThen(obj => Try.success(obj.key)); // Success('value') + +// Using orElse to recover from failures +const recoveredResult = Try.execute(() => JSON.parse('invalid json')) + .orElse(error => Try.success({ error: error.message })); // Success({ error: '...' }) + +// Chaining operations +const parseConfig = Try.execute(() => JSON.parse('{"port": 8080}')) + .andThen(config => { + if (config.port) { + return Try.success(`Server will run on port ${config.port}`); + } + return Try.failure(new Error('Port configuration missing')); + }) + .orElse(_ => Try.success('Server will run on default port 3000')); +// Result: Success('Server will run on port 8080') +``` + +You can use `combineWith` to combine multiple Try instances into one that contains a tuple of their values. This is +useful for operations that should all succeed or return the first error: + +```typescript +import { Try } from '@leanmind/monads'; + +// Database operations that may fail +const fetchUser = Try.execute(() => ({ id: 1, name: 'John' })); +const fetchPosts = Try.execute(() => [{ title: 'Hello World' }]); +const fetchComments = Try.execute(() => [{ text: 'Great post!' }]); + +// Combine all operations to get user data with posts and comments +const userData = fetchUser + .combineWith<[Array<{ title: string }>, Array<{ text: string }>]>([fetchPosts, fetchComments]) + .map(([user, posts, comments]) => ({ + user, + posts, + comments, + summary: `User ${user.name} has ${posts.length} posts and ${comments.length} comments` + })); + +// If all operations succeed: +// Success({ user: { id: 1, name: 'John' }, posts: [{ title: 'Hello World' }], comments: [{ text: 'Great post!' }], summary: 'User John has 1 posts and 1 comments' }) + +// If any operation fails, the result will contain the first error +const failingOperation = fetchUser + .combineWith<[Array<{ title: string }>, Array<{ text: string }>]>([ + Try.failure(new Error('Failed to fetch posts')), + fetchComments + ]) + .map(([user, posts, comments]) => ({ + user, + posts, + comments + })); + +// Result: Failure(Error('Failed to fetch posts')) +``` + #### Running side effects You can use the `onSuccess` method to run side effects on the value inside a `Success`. @@ -886,11 +1104,3 @@ const io = IO.of(() => 42).map(x => x + 1); io.runUnsafe(); // 43 ``` - - - - - - - - diff --git a/src/either/async-either.ts b/src/either/async-either.ts index 1f73f2c..baaa3fa 100644 --- a/src/either/async-either.ts +++ b/src/either/async-either.ts @@ -1,6 +1,7 @@ -import { Either } from './either'; +import { Either, FoldingEither } from './either'; import { Monad } from '../monad'; -import { AsyncRailway, Folding } from '../railway'; +import { AsyncRailway } from '../railway'; +import { AsyncFoldable, Folding } from '../fold'; /** * Class representing an asynchronous computation that may result in one of two possible types. @@ -8,7 +9,7 @@ import { AsyncRailway, Folding } from '../railway'; * @template L The type of the left value (usually an error). * @template R The type of the right value (usually a success). */ -export class AsyncEither implements PromiseLike>, Monad, AsyncRailway { +export class AsyncEither implements PromiseLike>, Monad, AsyncRailway, AsyncFoldable { private readonly promise: Promise>; private constructor(promise: Promise>) { @@ -162,15 +163,15 @@ export class AsyncEither implements PromiseLike>, Monad, A } /** - * Applies the appropriate function from the folding object based on whether the Either resolves to a Left or Right. + * Folds the AsyncEither instance into a single value using the provided folding functions. * @template L The type of the left value. * @template R The type of the right value. * @template T The return type of the folding functions. * @param {Folding<'Either', R, L, T>} folding The folding object with functions for handling Left and Right cases. * @returns {Promise} A promise that resolves to the result of the appropriate folding function. */ - async fold(folding: Folding<'Either', R, L, T>): Promise { - const either = await this; + async fold(folding: FoldingEither): Promise { + const either = await this.promise; return either.fold(folding); } diff --git a/src/either/either.test.ts b/src/either/either.test.ts index d230004..18d0124 100644 --- a/src/either/either.test.ts +++ b/src/either/either.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; import { Either } from './either'; -import { Option } from '../option'; describe('Either monad', () => { it.each([ @@ -16,18 +15,6 @@ describe('Either monad', () => { expect(Either.catch(execute)).toEqual(expected); }); - it.each([ - { typeFoldable: 'Some', eitherType: 'Right', foldable: Option.of(2), expected: Either.right(2) }, - { - typeFoldable: 'None', - eitherType: 'Left', - foldable: Option.of(undefined), - expected: Either.left(undefined), - }, - ])('$eitherType should be created from $typeFoldable', ({ foldable, expected }) => { - expect(Either.from(foldable)).toEqual(expected); - }); - it.each([ { type: 'Right', either: Either.right(2), closure: (x: number) => x, expected: Either.right(2) }, { type: 'Left', either: Either.left(2), closure: (x: number) => x * 2, expected: Either.left(4) }, diff --git a/src/either/either.ts b/src/either/either.ts index c7ab48d..62b6553 100644 --- a/src/either/either.ts +++ b/src/either/either.ts @@ -1,7 +1,8 @@ import { Monad } from '../monad'; import { Future } from '../future'; import { Futurizable } from '../futurizable'; -import { Folding, Railway } from '../railway'; +import { Railway } from '../railway'; +import { Foldable, Folding } from '../fold'; export type FoldingEither = Folding<'Either', R, L, T>; @@ -10,7 +11,7 @@ export type FoldingEither = Folding<'Either', R, L, T>; * @template L The type of the left value. * @template R The type of the right value. */ -abstract class Either implements Monad, Futurizable, Railway { +abstract class Either implements Monad, Futurizable, Foldable, Railway { /** * Creates a `Right` instance. * @template L The type of the left value. @@ -43,14 +44,14 @@ abstract class Either implements Monad, Futurizable, Railway { * Creates an `Either` instance from a `Foldable` instance. * @template L The type of the left value. * @template R The type of the right value. - * @param {Railway} other Railway instance. + * @param {Foldable} other Foldable instance. * @returns {Either} A `Right` instance if the railway contains a successful value, otherwise a `Left` instance. * @example * const option = Option.of(5); * const either = Either.from(option); * either.fold({ ifRight: console.log, ifLeft: error => console.error(error.message) }); // 5 */ - static from(other: Railway): Either { + static from(other: Foldable): Either { const folding = { ifRight: (value: R) => Either.right(value), ifLeft: (value: L) => Either.left(value), @@ -191,6 +192,19 @@ abstract class Either implements Monad, Futurizable, Railway { */ abstract orElse(transform: (value: L) => Either): Either; + /** + * Combines this `Either` instance with other `Either` instances. + * @template L The type of the left value. + * @template R The type of the right value. + * @template T The type of the combined values. + * @param {Either[]} others The other `Either` instances to combine with. + * @returns {Either} A new `Either` instance containing the combined values. + * @example + * const result = Either.right(5).combineWith([Either.right(10), Either.right(15)]); + * result.fold({ ifRight: console.log, ifLeft: error => console.error(error.message) }); // [5, 10, 15] + */ + abstract combineWith(others: Either[]): Either; + /** * Unwraps the value contained in this `Either` instance by applying the appropriate handler for both Left and Right cases. * @template R The type of the right value. @@ -288,6 +302,10 @@ class Left extends Either { return transform(this.value); } + combineWith(_: Either[]): Either { + return new Left(this.value); + } + fold(folding: FoldingEither): T { return folding.ifLeft(this.value); } @@ -356,6 +374,23 @@ class Right extends Either { return new Right(this.value); } + combineWith(others: Either[]): Either { + type UnwrapResult = { success: boolean; value: unknown | L }; + const isUnsuccessful = (result: UnwrapResult): result is { success: false; value: L } => !result.success; + const values: unknown[] = [this.value]; + for (const other of others) { + const result = other.fold({ + ifRight: (val) => ({ success: true, value: val }), + ifLeft: (err) => ({ success: false, value: err }), + }); + if (isUnsuccessful(result)) { + return Either.left(result.value); + } + values.push(result.value); + } + return Either.right(values as [R, ...T]); + } + fold(folding: FoldingEither): T { return folding.ifRight(this.value); } diff --git a/src/fold/async-foldable.test.ts b/src/fold/async-foldable.test.ts new file mode 100644 index 0000000..4928f16 --- /dev/null +++ b/src/fold/async-foldable.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { AsyncEither } from '../either'; + +describe('AsyncFoldable', () => { + it.each(foldTestCases)( + 'AsyncEither $type should handle fold operation correctly', + async ({ foldable, folding, expected }) => { + const result = await foldable.fold(folding); + expect(result).toEqual(expected); + } + ); +}); + +const foldTestCases = [ + { + type: 'Right', + foldable: AsyncEither.fromSafePromise(Promise.resolve(2)), + folding: { + ifRight: (value: number) => `value: ${value}`, + ifLeft: (e: string) => e + '!', + }, + expected: 'value: 2', + }, + { + type: 'Left', + foldable: AsyncEither.fromPromise(Promise.reject('error'), (error) => error as string), + folding: { + ifRight: (value: number) => `value: ${value}`, + ifLeft: (e: string) => e + '!', + }, + expected: 'error!', + }, +]; diff --git a/src/fold/async-foldable.ts b/src/fold/async-foldable.ts new file mode 100644 index 0000000..6bccd45 --- /dev/null +++ b/src/fold/async-foldable.ts @@ -0,0 +1,7 @@ +import { Folding, Kind } from './foldable'; + +interface AsyncFoldable { + fold(folding: Folding): Promise; +} + +export { AsyncFoldable }; diff --git a/src/fold/foldable.test.ts b/src/fold/foldable.test.ts new file mode 100644 index 0000000..6e443e3 --- /dev/null +++ b/src/fold/foldable.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { Either } from '../either'; +import { Try } from '../try'; +import { Option } from '../option'; + +describe('Foldable', () => { + it.each(foldTestCases)('$type should handle fold operation correctly', ({ foldable, folding, expected }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(foldable.fold(folding)).toEqual(expected); + }); +}); + +const foldTestCases = [ + { + type: 'Either Right', + foldable: Either.right(2), + folding: { + ifRight: (x: number) => x * 2, + ifLeft: (_: number) => 2, + }, + expected: 4, + }, + { + type: 'Either Left', + foldable: Either.left(2), + folding: { + ifRight: (_: number) => 2, + ifLeft: (x: number) => x * 2, + }, + expected: 4, + }, + { + type: 'Try Success', + foldable: Try.success(2), + folding: { + ifSuccess: (x: number) => x * 2, + ifFailure: (_: Error) => 2, + }, + expected: 4, + }, + { + type: 'Try Failure', + foldable: Try.failure(new Error('fail')), + folding: { + ifSuccess: (_: number) => 2, + ifFailure: (e: Error) => e.message, + }, + expected: 'fail', + }, + { + type: 'Option Some', + foldable: Option.of(2), + folding: { + ifSome: (x: number) => x * 2, + ifNone: () => 2, + }, + expected: 4, + }, + { + type: 'Option None', + foldable: Option.of(undefined), + folding: { + ifSome: (_: number) => 2, + ifNone: (x: undefined) => x, + }, + expected: undefined, + }, +]; diff --git a/src/fold/foldable.ts b/src/fold/foldable.ts new file mode 100644 index 0000000..e59e6aa --- /dev/null +++ b/src/fold/foldable.ts @@ -0,0 +1,23 @@ +type Kind = 'Option' | 'Either' | 'Try'; + +type OptionFoldingKeys = ['ifSome', 'ifNone']; +type EitherFoldingKeys = ['ifRight', 'ifLeft']; +type TryFoldingKeys = ['ifSuccess', 'ifFailure']; + +type Pair = K extends 'Option' + ? OptionFoldingKeys + : K extends 'Either' + ? EitherFoldingKeys + : K extends 'Try' + ? TryFoldingKeys + : never; + +type Folding = { + [success in Pair[0]]: (value: Acceptable) => T; +} & { [failure in Pair[1]]: (value: Unacceptable) => T }; + +interface Foldable { + fold(folding: Folding): T; +} + +export { Foldable, Folding, Kind }; diff --git a/src/fold/index.ts b/src/fold/index.ts new file mode 100644 index 0000000..b10abeb --- /dev/null +++ b/src/fold/index.ts @@ -0,0 +1,2 @@ +export { Foldable, Folding } from './foldable'; +export { AsyncFoldable } from './async-foldable'; diff --git a/src/option/option.ts b/src/option/option.ts index 9294681..7e8bd05 100644 --- a/src/option/option.ts +++ b/src/option/option.ts @@ -2,7 +2,8 @@ import { Nullable, Present } from '../types'; import { Monad } from '../monad'; import { Futurizable } from '../futurizable'; import { Future } from '../future'; -import { Folding, Railway } from '../railway'; +import { Railway } from '../railway'; +import { Foldable, Folding } from '../fold'; type FoldingOption = Folding<'Option', T, undefined, U>; @@ -10,7 +11,7 @@ type FoldingOption = Folding<'Option', T, undefined, U>; * Abstract class representing an optional value. * @template T The type of the value. */ -abstract class Option implements Monad, Futurizable, Railway { +abstract class Option implements Monad, Futurizable, Foldable, Railway { /** * Creates an `Option` instance from a nullable value. * @template T The type of the value. @@ -57,14 +58,14 @@ abstract class Option implements Monad, Futurizable, Railway} foldable The foldable instance. + * @param {Foldable} foldable The foldable instance. * @returns {Option} A `Some` instance if the foldable contains a value, otherwise a `None` instance. * @example * const either = Either.right(5); * const option = Option.from(either); * option.fold({ ifSome: console.log, ifNone: () => console.log('none') }); // 5 */ - static from(foldable: Railway): Option { + static from(foldable: Foldable): Option { return foldable.fold>({ ifSome: (value: T) => Option.of(value), ifNone: () => Option.none(), @@ -167,6 +168,19 @@ abstract class Option implements Monad, Futurizable, Railway Option): Option; + /** + * Combines this `Option` instance with other `Railway` instances. + * @template T The type of the value. + * @template U The type of the combined value. + * @param {Railway[]} others The other `Railway` instances to combine with. + * @returns {Option<[T, ...U]>} A new `Option` instance containing the combined values. + * @example + * const some = Option.of(5); + * const result = some.combineWith([Option.of(10), Option.of(15)]); + * result.fold({ ifSome: console.log, ifNone: () => console.log('none') }); // [5, 10, 15] + */ + abstract combineWith(others: Option[]): Option<[T, ...U]>; + /** * Unwraps the value contained in this `Option` instance by applying the appropriate handler for both Some and None cases. * @template T The type of the value. @@ -259,6 +273,23 @@ class Some extends Option { return new Some(this.value); } + combineWith(others: Option[]): Option<[T, ...U]> { + type UnwrapResult = { success: boolean; value?: unknown }; + const values: unknown[] = [this.value]; + for (const other of others) { + const result = other.fold({ + ifSome: (val) => ({ success: true, value: val }), + ifNone: () => ({ success: false }), + }); + if (!result.success) { + return Option.none(); + } + values.push(result.value); + } + + return Option.some(values as [T, ...U]); + } + fold(folding: FoldingOption): U { return folding.ifSome(this.value); } @@ -309,10 +340,15 @@ class None extends Option { andThen(_: (value: T) => Option): Option { return new None(); } + orElse(transform: (value: undefined) => Option): Option { return transform(undefined); } + combineWith(_: Option[]): Option<[T, ...U]> { + return new None(); + } + fold(folding: FoldingOption): U { return folding.ifNone(undefined); } diff --git a/src/railway/async-railway.test.ts b/src/railway/async-railway.test.ts index 0b6f352..b7f9730 100644 --- a/src/railway/async-railway.test.ts +++ b/src/railway/async-railway.test.ts @@ -2,14 +2,6 @@ import { describe, expect, it, vi } from 'vitest'; import { AsyncEither } from '../either'; describe('AsyncRailway', () => { - it.each(foldTestCases)( - 'AsyncEither $type should handle fold operation correctly', - async ({ railway, folding, expected }) => { - const result = await railway.fold(folding); - expect(result).toEqual(expected); - } - ); - it.each(andThenTestCases)( 'AsyncEither $type should handle andThen operation correctly', async ({ railway, operation, expected }) => { @@ -72,27 +64,6 @@ describe('AsyncRailway', () => { }); }); -const foldTestCases = [ - { - type: 'Right', - railway: AsyncEither.fromSafePromise(Promise.resolve(2)), - folding: { - ifRight: (value: number) => `value: ${value}`, - ifLeft: (e: string) => e + '!', - }, - expected: 'value: 2', - }, - { - type: 'Left', - railway: AsyncEither.fromPromise(Promise.reject('error'), (error) => error as string), - folding: { - ifRight: (value: number) => `value: ${value}`, - ifLeft: (e: string) => e + '!', - }, - expected: 'error!', - }, -]; - const andThenTestCases = [ { type: 'Right', diff --git a/src/railway/async-railway.ts b/src/railway/async-railway.ts index a00ba80..5ec4122 100644 --- a/src/railway/async-railway.ts +++ b/src/railway/async-railway.ts @@ -1,10 +1,9 @@ -import { Folding, Kind, Railway } from './railway'; +import { Railway } from './railway'; type AnyRailway = AsyncRailway | Railway; export interface AsyncRailway { andThen(transform: (value: Acceptable) => AnyRailway): AsyncRailway; orElse(transform: (value: Unacceptable) => AnyRailway): AsyncRailway; - fold(folding: Folding): Promise; withTimeout(ms: number, onTimeout: () => Unacceptable): AsyncRailway; } diff --git a/src/railway/index.ts b/src/railway/index.ts index 97fea0a..81b3643 100644 --- a/src/railway/index.ts +++ b/src/railway/index.ts @@ -1,2 +1,2 @@ -export type { Folding, Railway } from './railway'; +export type { Railway } from './railway'; export { AsyncRailway } from './async-railway'; diff --git a/src/railway/railway.test.ts b/src/railway/railway.test.ts index 7c8636b..c01152e 100644 --- a/src/railway/railway.test.ts +++ b/src/railway/railway.test.ts @@ -4,11 +4,6 @@ import { Either } from '../either'; import { Option } from '../option'; describe('Railway', () => { - it.each(foldTestCases)('$type should handle fold operation correctly', ({ railway, folding, expected }) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - expect(railway.fold(folding)).toEqual(expected); - }); it.each(andThenTestCases)('$type should handle andThen operation correctly', ({ railway, operation, expected }) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -19,64 +14,16 @@ describe('Railway', () => { // @ts-expect-error expect(railway.orElse(operation)).toEqual(expected); }); -}); -const foldTestCases = [ - { - type: 'Either Right', - railway: Either.right(2), - folding: { - ifRight: (x: number) => x * 2, - ifLeft: (_: number) => 2, - }, - expected: 4, - }, - { - type: 'Either Left', - railway: Either.left(2), - folding: { - ifRight: (_: number) => 2, - ifLeft: (x: number) => x * 2, - }, - expected: 4, - }, - { - type: 'Try Success', - railway: Try.success(2), - folding: { - ifSuccess: (x: number) => x * 2, - ifFailure: (_: Error) => 2, - }, - expected: 4, - }, - { - type: 'Try Failure', - railway: Try.failure(new Error('fail')), - folding: { - ifSuccess: (_: number) => 2, - ifFailure: (e: Error) => e.message, - }, - expected: 'fail', - }, - { - type: 'Option Some', - railway: Option.of(2), - folding: { - ifSome: (x: number) => x * 2, - ifNone: () => 2, - }, - expected: 4, - }, - { - type: 'Option None', - railway: Option.of(undefined), - folding: { - ifSome: (_: number) => 2, - ifNone: (x: undefined) => x, - }, - expected: undefined, - }, -]; + it.each(combineWithTestCases)( + '$type should handle combineWith operation correctly', + ({ railway, others, expected }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(railway.combineWith(others)).toEqual(expected); + } + ); +}); const andThenTestCases = [ { @@ -155,3 +102,77 @@ const orElseTestCases = [ expected: Option.of(2), }, ]; + +const combineWithTestCases = [ + { + type: 'Either Right with Rights', + railway: Either.right(2), + others: [Either.right('test'), Either.right(true)], + expected: Either.right([2, 'test', true]), + }, + { + type: 'Either Right with Left', + railway: Either.right(2), + others: [Either.right('test'), Either.left('error')], + expected: Either.left('error'), + }, + { + type: 'Either Left with others', + railway: Either.left('initial error'), + others: [Either.right('test'), Either.right(true)], + expected: Either.left('initial error'), + }, + { + type: 'Try Success with Successes', + railway: Try.success(2), + others: [Try.success('test'), Try.success(true)], + expected: Try.success([2, 'test', true]), + }, + { + type: 'Try Success with Failure', + railway: Try.success(2), + others: [Try.success('test'), Try.failure(new Error('error'))], + expected: Try.failure(new Error('error')), + }, + { + type: 'Try Failure with others', + railway: Try.failure(new Error('initial error')), + others: [Try.success('test'), Try.success(true)], + expected: Try.failure(new Error('initial error')), + }, + { + type: 'Option Some with Somes', + railway: Option.of(2), + others: [Option.of('test'), Option.of(true)], + expected: Option.of([2, 'test', true]), + }, + { + type: 'Option Some with None', + railway: Option.of(2), + others: [Option.of('test'), Option.of(undefined)], + expected: Option.of(undefined), + }, + { + type: 'Option None with others', + railway: Option.of(undefined), + others: [Option.of('test'), Option.of(true)], + expected: Option.of(undefined), + }, + { + type: 'Empty array of others', + railway: Either.right(2), + others: [], + expected: Either.right([2]), + }, + { + type: 'Multiple values combined', + railway: Either.right(1), + others: [ + Either.right(2), + Either.right(3), + Either.right(4), + Either.right(5), + ], + expected: Either.right([1, 2, 3, 4, 5]), + }, +]; diff --git a/src/railway/railway.ts b/src/railway/railway.ts index 7062d78..119364a 100644 --- a/src/railway/railway.ts +++ b/src/railway/railway.ts @@ -1,25 +1,7 @@ -export type Kind = 'Option' | 'Either' | 'Try'; - -type OptionFoldingKeys = ['ifSome', 'ifNone']; -type EitherFoldingKeys = ['ifRight', 'ifLeft']; -type TryFoldingKeys = ['ifSuccess', 'ifFailure']; - -export type Pair = K extends 'Option' - ? OptionFoldingKeys - : K extends 'Either' - ? EitherFoldingKeys - : K extends 'Try' - ? TryFoldingKeys - : never; - -type Folding = { - [success in Pair[0]]: (value: Acceptable) => T; -} & { [failure in Pair[1]]: (value: Unacceptable) => T }; - interface Railway { - andThen(f: (value: Acceptable) => Railway): Railway; - orElse(f: (value: Unacceptable) => Railway): Railway; - fold(folding: Folding): R; + andThen(transform: (value: Acceptable) => Railway): Railway; + orElse(f: (value: Unacceptable) => Railway): Railway; + combineWith(others: Railway[]): Railway<[Acceptable, ...T], Unacceptable>; } -export { Railway, Folding }; +export { Railway }; diff --git a/src/try/try.ts b/src/try/try.ts index 90d4338..bded761 100644 --- a/src/try/try.ts +++ b/src/try/try.ts @@ -1,7 +1,8 @@ import { Monad } from '../monad'; import { Futurizable } from '../futurizable'; import { Future } from '../future'; -import { Folding, Railway } from '../railway'; +import { Railway } from '../railway'; +import { Foldable, Folding } from '../fold'; type FoldingTry = Folding<'Try', T, Error, U>; @@ -56,7 +57,7 @@ abstract class Try implements Monad, Futurizable, Railway { /** * Creates a `Try` instance from a `Foldable` instance. * @template T The type of the value. - * @param {Railway} foldable The foldable instance. + * @param {Foldable} foldable The foldable instance. * @returns {Try} A `Success` instance if the foldable contains a value, otherwise a `Failure` instance. * @example * const some = Option.of(5); @@ -67,7 +68,7 @@ abstract class Try implements Monad, Futurizable, Railway { * const failure = Try.from(none); * failure.fold({ ifSuccess: console.log, ifFailure: error => console.error(error.message) }); // Empty value */ - static from(foldable: Railway): Try { + static from(foldable: Foldable): Try { return foldable.fold>({ ifSuccess: (value: T) => Try.success(value), ifFailure: (error: unknown) => Try.failure(error as Error), @@ -146,6 +147,18 @@ abstract class Try implements Monad, Futurizable, Railway { */ abstract orElse(transform: (error: Error) => Try): Try; + /** + * Combines this `Try` instance with another `Railway` instance. + * @template T The type of the value. + * @template U The types of the combined value. + * @param {Try[]} others The other `Try` instances to combine with. + * @returns {Try<[T, ...U]>} A new `Try` instance containing the combined values. + * @example + * const result = Try.execute(() => 5).combineWith([Try.success(10), Try.success(15)]); + * result.fold({ ifSuccess: console.log, ifFailure: error => console.error(error.message) }); // [5, 10, 15] + */ + abstract combineWith(others: Try[]): Try<[T, ...U]>; + /** * Unwraps the value contained in this `Try` instance by applying the appropriate handler for both Success and Failure cases. * @template T The type of the value. @@ -255,6 +268,24 @@ class Success extends Try { return new Success(this.value); } + combineWith(others: Try[]): Try<[T, ...U]> { + type UnwrapResult = { success: boolean; value: unknown | Error }; + const isUnsuccessful = (result: UnwrapResult): result is { success: false; value: Error } => !result.success; + const values: unknown[] = [this.value]; + for (const other of others) { + const result = other.fold({ + ifSuccess: (val: unknown) => ({ success: true, value: val }), + ifFailure: (err: Error) => ({ success: false, value: err }), + }); + if (isUnsuccessful(result)) { + return Try.failure(result.value); + } + values.push(result.value); + } + + return Try.success(values as [T, ...U]); + } + fold(folding: FoldingTry): U { return folding.ifSuccess(this.value); } @@ -293,11 +324,6 @@ class Failure extends Try { super(); } - /** - * A static instance representing a failure with no error provided. - * @type {Failure} - */ - map(_: (_: never) => never): Try { return new Failure(this.error); } @@ -316,13 +342,17 @@ class Failure extends Try { } andThen(_: (value: T) => Try): Try { - return new Failure(this.error); + return new Failure(this.error); } orElse(transform: (error: Error) => Try): Try { return transform(this.error); } + combineWith(_: Try[]): Try<[T, ...U]> { + return new Failure(this.error); + } + fold(folding: FoldingTry): U { return folding.ifFailure(this.error); }