From d4959f55b40dcc76cd48215a066fad31054aa6ba Mon Sep 17 00:00:00 2001 From: Miguel Ignacio Cabrera San Gil Date: Wed, 14 May 2025 22:09:22 +0100 Subject: [PATCH 1/3] feat(railway): refactor to use Railway type for better abstraction --- .github/workflows/release.yml | 24 ++++++ src/either/either.ts | 79 +++++++++++++---- src/either/index.ts | 1 + src/fold/foldable.test.ts | 72 ---------------- src/fold/foldable.ts | 21 ----- src/fold/index.ts | 1 - src/option/option.ts | 46 +++++++++- src/railway/index.ts | 1 + src/railway/railway.test.ts | 157 ++++++++++++++++++++++++++++++++++ src/railway/railway.ts | 25 ++++++ src/try/try.ts | 48 ++++++++++- 11 files changed, 355 insertions(+), 120 deletions(-) delete mode 100644 src/fold/foldable.test.ts delete mode 100644 src/fold/foldable.ts delete mode 100644 src/fold/index.ts create mode 100644 src/railway/index.ts create mode 100644 src/railway/railway.test.ts create mode 100644 src/railway/railway.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8423cc..bd40c85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,27 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release + # Sync (rebase) beta branch with the latest main changes + - name: Sync beta branch with main + if: github.ref == 'refs/heads/main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.name "GitHub Actions Bot" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + # Ensure we have the latest changes from main and beta branches + git fetch origin main beta + + # Configure remote URL to use the GitHub token for authentication + git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git + + echo "🔄 Rebasing beta branch with main" + if git checkout beta && git rebase origin/main; then + git push origin beta --force + echo "✅ Beta branch updated successfully" + else + echo "❌ Failed to update beta branch" + git rebase --abort || true + exit 1 + fi diff --git a/src/either/either.ts b/src/either/either.ts index 94f3ac1..c7ab48d 100644 --- a/src/either/either.ts +++ b/src/either/either.ts @@ -1,7 +1,7 @@ import { Monad } from '../monad'; import { Future } from '../future'; import { Futurizable } from '../futurizable'; -import { Foldable, Folding } from '../fold'; +import { Folding, Railway } from '../railway'; export type FoldingEither = Folding<'Either', R, L, T>; @@ -10,7 +10,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, Foldable { +abstract class Either implements Monad, Futurizable, Railway { /** * Creates a `Right` instance. * @template L The type of the left value. @@ -43,23 +43,23 @@ abstract class Either implements Monad, Futurizable, Foldable * 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 {Foldable} foldable The foldable instance. - * @returns {Either} A `Right` instance if the foldable contains a right value, otherwise a `Left` instance. + * @param {Railway} other Railway 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(foldable: Foldable): Either { + static from(other: Railway): Either { const folding = { - ifRight: (value: R): Either => Either.right(value), + ifRight: (value: R) => Either.right(value), ifLeft: (value: L) => Either.left(value), ifSuccess: (value: R) => Either.right(value), ifFailure: (value: L) => Either.left(value), ifSome: (value: R) => Either.right(value), ifNone: (value: L) => Either.left(value), }; - return foldable.fold>(folding); + return other.fold>(folding); } /** @@ -104,9 +104,9 @@ abstract class Either implements Monad, Futurizable, Foldable */ abstract mapLeft(transform: (l: L) => T): Either; - abstract flatMap(transform: (r: R) => Either): Either; /** * Transforms the right value contained in this `Either` instance into another `Either` instance. + * @template L The type of the left value. * @template R The type of the right value. * @template T The type of the transformed value. * @param {(r: R) => Either} transform The transformation function. @@ -115,6 +115,7 @@ abstract class Either implements Monad, Futurizable, Foldable * const result = Either.right(5).flatMap(value => Either.right(value * 2)); * result.fold({ ifRight: console.log, ifLeft: error => console.error(error.message) }); // 10 */ + abstract flatMap(transform: (r: R) => Either): Either; /** * Transforms the left value contained in this `Either` instance into another `Either` instance. @@ -164,6 +165,32 @@ abstract class Either implements Monad, Futurizable, Foldable */ abstract onLeft(action: (l: L) => void): Either; + /** + * Transforms the right value contained in this `Either` instance into another `Either` instance. + * @template L The type of the left value. + * @template R The type of the right value. + * @template T The type of the transformed value. + * @param {(r: R) => Either} transform The transformation function. + * @returns {Either} The result of the transformation function. + * @example + * const result = Either.right(5).andThen(value => Either.right(value * 2)); + * result.fold({ ifRight: console.log, ifLeft: error => console.error(error.message) }); // 10 + */ + abstract andThen(transform: (value: R) => Either): Either; + + /** + * Transforms the left value contained in this `Either` instance into another `Either` instance. + * @template L The type of the left value. + * @template T The type of the transformed value. + * @template R The type of the right value. + * @param {(l: L) => Either} transform The transformation function. + * @returns {Either} The result of the transformation function. + * @example + * const result = Either.left('error').orElse(value => Either.left(`Error: ${value}`)); + * result.fold({ ifRight: console.log, ifLeft: error => console.error(error.message) }); // Error: Error: error + */ + abstract orElse(transform: (value: L) => 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. @@ -228,7 +255,7 @@ class Left extends Either { return new Left(this.value); } - mapLeft(transform: (l: L) => T): Either { + mapLeft(transform: (l: L) => T): Either { return new Left(transform(this.value)); } @@ -236,11 +263,11 @@ class Left extends Either { return new Left(this.value); } - flatMapLeft(transform: (l: L) => Either): Either { + flatMapLeft(transform: (l: L) => Either): Either { return transform(this.value); } - recover(transform: (l: L) => Either): Either { + recover(transform: (l: L) => Either): Either { return transform(this.value); } @@ -253,19 +280,27 @@ class Left extends Either { return this; } + andThen(_: (_: R) => Either): Either { + return new Left(this.value); + } + + orElse(transform: (value: L) => Either): Either { + return transform(this.value); + } + fold(folding: FoldingEither): T { return folding.ifLeft(this.value); } - isLeft(): this is Left { + isLeft(): this is Left { return true; } - isRight(): this is Right { + isRight(): this is Right { return false; } - toFuture(): Future { + toFuture(): Future { return Future.of(() => Promise.reject(new Error(this.value?.toString() ?? 'Unknown error'))); } } @@ -284,7 +319,7 @@ class Right extends Either { super(); } - map(transform: (r: R) => T): Either { + map(transform: (r: R) => T): Either { return new Right(transform(this.value)); } @@ -292,7 +327,7 @@ class Right extends Either { return new Right(this.value); } - flatMap(transform: (r: R) => Either): Either { + flatMap(transform: (r: R) => Either): Either { return transform(this.value); } @@ -313,15 +348,23 @@ class Right extends Either { return this; } + andThen(f: (value: R) => Either): Either { + return f(this.value); + } + + orElse(f: (value: L) => Either): Either { + return new Right(this.value); + } + fold(folding: FoldingEither): T { return folding.ifRight(this.value); } - isLeft(): this is Left { + isLeft(): this is Left { return false; } - isRight(): this is Right { + isRight(): this is Right { return true; } diff --git a/src/either/index.ts b/src/either/index.ts index a3818e6..5ad06e7 100644 --- a/src/either/index.ts +++ b/src/either/index.ts @@ -1 +1,2 @@ export { Either, Right, Left } from './either'; +export { AsyncEither } from './async-either'; diff --git a/src/fold/foldable.test.ts b/src/fold/foldable.test.ts deleted file mode 100644 index fcf257d..0000000 --- a/src/fold/foldable.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Try } from '../try'; -import { Either } from '../either'; -import { Option } from '../option'; - -const testsFoldableCases = [ - { - 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, - }, -]; - -describe('Foldable', () => { - it.each(testsFoldableCases)( - '$type foldable 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); - } - ); -}); diff --git a/src/fold/foldable.ts b/src/fold/foldable.ts deleted file mode 100644 index c2bb502..0000000 --- a/src/fold/foldable.ts +++ /dev/null @@ -1,21 +0,0 @@ -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; - -export type Folding = { - [success in Pair[0]]: (value: H) => T; -} & { [failure in Pair[1]]: (value: W) => T }; - -export interface Foldable { - fold(folding: Folding): R; -} diff --git a/src/fold/index.ts b/src/fold/index.ts deleted file mode 100644 index d9f8955..0000000 --- a/src/fold/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { Folding, Foldable } from './foldable'; diff --git a/src/option/option.ts b/src/option/option.ts index 3768115..9294681 100644 --- a/src/option/option.ts +++ b/src/option/option.ts @@ -2,7 +2,7 @@ import { Nullable, Present } from '../types'; import { Monad } from '../monad'; import { Futurizable } from '../futurizable'; import { Future } from '../future'; -import { Foldable, Folding } from '../fold'; +import { Folding, Railway } from '../railway'; type FoldingOption = Folding<'Option', T, undefined, U>; @@ -10,7 +10,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, Foldable { +abstract class Option implements Monad, Futurizable, Railway { /** * Creates an `Option` instance from a nullable value. * @template T The type of the value. @@ -57,14 +57,14 @@ abstract class Option implements Monad, Futurizable, Foldable} foldable The foldable instance. + * @param {Railway} 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: Foldable): Option { + static from(foldable: Railway): Option { return foldable.fold>({ ifSome: (value: T) => Option.of(value), ifNone: () => Option.none(), @@ -144,6 +144,29 @@ abstract class Option implements Monad, Futurizable, Foldable void): Option; + /** + * Transforms a `Some` into another `Option` instance. + * @template T The type of the value. + * @template U The type of the transformed value. + * @param {(value: T) => Option} transform The transformation function. + * @returns {Option} The result of the transformation function. + * @example + * const some = Option.of(5).andThen(value => Option.of(value * 2)); + * some.fold({ ifSome: console.log, ifNone: () => console.log('none') }); // 10 + */ + abstract andThen(transform: (value: T) => Option): Option; + + /** + * Transforms a `None` into another `Option` instance. + * @template T The type of the value. + * @param {(value: undefined) => Option} transform The transformation function. + * @returns {Option} The result of the transformation function. + * @example + * const none = Option.of(undefined).orElse(value => Option.of(5)); + * none.fold({ ifSome: console.log, ifNone: () => console.log('none') }); // 5 + */ + abstract orElse(transform: (value: undefined) => Option): Option; + /** * 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. @@ -228,6 +251,14 @@ class Some extends Option { return this; } + andThen(transform: (value: T) => Option): Option { + return transform(this.value); + } + + orElse(_: (value: undefined) => Option): Option { + return new Some(this.value); + } + fold(folding: FoldingOption): U { return folding.ifSome(this.value); } @@ -275,6 +306,13 @@ class None extends Option { return this; } + andThen(_: (value: T) => Option): Option { + return new None(); + } + orElse(transform: (value: undefined) => Option): Option { + return transform(undefined); + } + fold(folding: FoldingOption): U { return folding.ifNone(undefined); } diff --git a/src/railway/index.ts b/src/railway/index.ts new file mode 100644 index 0000000..3ae7de0 --- /dev/null +++ b/src/railway/index.ts @@ -0,0 +1 @@ +export type { Folding, Railway } from './railway'; diff --git a/src/railway/railway.test.ts b/src/railway/railway.test.ts new file mode 100644 index 0000000..49858d8 --- /dev/null +++ b/src/railway/railway.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { Try } from '../try'; +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 + expect(railway.andThen(operation)).toEqual(expected); + }); + it.each(orElseTestCases)('$type should handle orElse operation correctly', ({ railway, operation, expected }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @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, + }, +]; + +const andThenTestCases = [ + { + type: 'Either Right', + railway: Either.right(2), + operation: (x: number) => Either.right(x * 2), + expected: Either.right(4), + }, + { + type: 'Either Left', + railway: Either.left(2), + operation: (x: number) => Either.right(x * 2), + expected: Either.left(2), + }, + { + type: 'Try Success', + railway: Try.success(2), + operation: (x: number) => Try.success(x * 2), + expected: Try.success(4), + }, + { + type: 'Try Failure', + railway: Try.failure(new Error('fail')), + operation: (x: number) => Try.success(x * 2), + expected: Try.failure(new Error('fail')), + }, + { + type: 'Option Some', + railway: Option.of(2), + operation: (x: number) => Option.of(x * 2), + expected: Option.of(4), + }, + { + type: 'Option None', + railway: Option.of(undefined), + operation: (x: number) => Option.of(x * 2), + expected: Option.of(undefined), + }, +]; + +const orElseTestCases = [ + { + type: 'Either Right', + railway: Either.right(2), + operation: (x: number) => Either.left(x * 2), + expected: Either.right(2), + }, + { + type: 'Either Left', + railway: Either.left(2), + operation: (x: number) => Either.left(x * 2), + expected: Either.left(4), + }, + { + type: 'Try Success', + railway: Try.success(2), + operation: (x: number) => Try.failure(new Error('fail')), + expected: Try.success(2), + }, + { + type: 'Try Failure', + railway: Try.failure(new Error('fail')), + operation: (x: number) => Try.failure(new Error('fail')), + expected: Try.failure(new Error('fail')), + }, + { + type: 'Option Some', + railway: Option.of(2), + operation: (x: number) => Option.of(x * 2), + expected: Option.of(2), + }, + { + type: 'Option None', + railway: Option.of(undefined), + operation: () => Option.of(2), + expected: Option.of(2), + }, +]; diff --git a/src/railway/railway.ts b/src/railway/railway.ts new file mode 100644 index 0000000..a38ba4a --- /dev/null +++ b/src/railway/railway.ts @@ -0,0 +1,25 @@ +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; +} + +export { Railway, Folding }; diff --git a/src/try/try.ts b/src/try/try.ts index 89d995a..90d4338 100644 --- a/src/try/try.ts +++ b/src/try/try.ts @@ -1,7 +1,7 @@ import { Monad } from '../monad'; import { Futurizable } from '../futurizable'; import { Future } from '../future'; -import { Foldable, Folding } from '../fold'; +import { Folding, Railway } from '../railway'; type FoldingTry = Folding<'Try', T, Error, U>; @@ -9,7 +9,7 @@ type FoldingTry = Folding<'Try', T, Error, U>; * Abstract class representing a computation that may either result in a value or an error. * @template T The type of the value. */ -abstract class Try implements Monad, Futurizable, Foldable { +abstract class Try implements Monad, Futurizable, Railway { /** * Executes a function and returns a `Try` instance. * @template T The type of the value. @@ -56,7 +56,7 @@ abstract class Try implements Monad, Futurizable, Foldable { /** * Creates a `Try` instance from a `Foldable` instance. * @template T The type of the value. - * @param {Foldable} foldable The foldable instance. + * @param {Railway} 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 +67,7 @@ abstract class Try implements Monad, Futurizable, Foldable { * const failure = Try.from(none); * failure.fold({ ifSuccess: console.log, ifFailure: error => console.error(error.message) }); // Empty value */ - static from(foldable: Foldable): Try { + static from(foldable: Railway): Try { return foldable.fold>({ ifSuccess: (value: T) => Try.success(value), ifFailure: (error: unknown) => Try.failure(error as Error), @@ -122,6 +122,30 @@ abstract class Try implements Monad, Futurizable, Foldable { */ abstract onFailure(action: (error: Error) => void): Try; + /** + * Transforms a `Success` into another `Try` instance. + * @template T The type of the value. + * @template R The type of the transformed value. + * @param {(value: T) => Try} transform The transformation function. + * @returns {Try} The result of the transformation function. + * @example + * const result = Try.execute(() => 5).andThen(value => Try.execute(() => value * 2)); + * result.fold({ ifSuccess: console.log, ifFailure: error => console.error(error.message) }); // 10 + */ + abstract andThen(transform: (value: T) => Try): Try; + + /** + * Transforms a `Failure` into another `Try` instance. + * @template T The type of the value. + * @template R The type of the transformed value. + * @param {(error: Error) => Try} transform The transformation function. + * @returns {Try} The result of the transformation function. + * @example + * const result = Try.execute(() => { throw new Error('failure'); }).orElse(error => Try.execute(() => 0)); + * result.fold({ ifSuccess: console.log, ifFailure: error => console.error(error.message) }); // 0 + */ + abstract orElse(transform: (error: Error) => Try): Try; + /** * 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. @@ -223,6 +247,14 @@ class Success extends Try { return this; } + andThen(transform: (value: T) => Try): Try { + return transform(this.value); + } + + orElse(_: (error: Error) => Try): Try { + return new Success(this.value); + } + fold(folding: FoldingTry): U { return folding.ifSuccess(this.value); } @@ -283,6 +315,14 @@ class Failure extends Try { return this; } + andThen(_: (value: T) => Try): Try { + return new Failure(this.error); + } + + orElse(transform: (error: Error) => Try): Try { + return transform(this.error); + } + fold(folding: FoldingTry): U { return folding.ifFailure(this.error); } From ab643f4be006d07513bd60db5f5f618f218e0820 Mon Sep 17 00:00:00 2001 From: Miguel Ignacio Cabrera San Gil Date: Thu, 15 May 2025 00:57:55 +0100 Subject: [PATCH 2/3] feat(async-either): enhance AsyncEither with AsyncRailway interface and timeout support --- src/either/async-either.ts | 64 +++++++++++---- src/railway/async-railway.test.ts | 125 ++++++++++++++++++++++++++++++ src/railway/async-railway.ts | 10 +++ src/railway/index.ts | 1 + src/railway/railway.test.ts | 4 +- src/railway/railway.ts | 2 +- 6 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 src/railway/async-railway.test.ts create mode 100644 src/railway/async-railway.ts diff --git a/src/either/async-either.ts b/src/either/async-either.ts index 2180a62..1f73f2c 100644 --- a/src/either/async-either.ts +++ b/src/either/async-either.ts @@ -1,5 +1,6 @@ -import { Either, FoldingEither } from './either'; +import { Either } from './either'; import { Monad } from '../monad'; +import { AsyncRailway, Folding } from '../railway'; /** * Class representing an asynchronous computation that may result in one of two possible types. @@ -7,7 +8,7 @@ import { Monad } from '../monad'; * @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 { +export class AsyncEither implements PromiseLike>, Monad, AsyncRailway { private readonly promise: Promise>; private constructor(promise: Promise>) { @@ -136,25 +137,58 @@ export class AsyncEither implements PromiseLike>, Monad { } /** - * Unwraps the value contained in this AsyncEither instance by applying the appropriate handler for both Left and Right cases. + * Transforms the right value contained in this AsyncEither instance into another AsyncEither instance. + * Implementation of the andThen method from the AsyncRailway interface. + * @template L The type of the left value. * @template R The type of the right value. + * @template U The type of the new right value. + * @param {(value: R) => AsyncRailway | Railway} transform The transformation function. + * @returns {AsyncEither} A new AsyncEither instance containing the result of the transformation. + */ + andThen(transform: (value: R) => AsyncEither | Either): AsyncEither { + return this.flatMap(transform); + } + + /** + * Transforms the left value contained in this AsyncEither instance into another AsyncEither instance. * @template L The type of the left value. - * @template T The type of the result. - * @param {FoldingEither} folding The folding object containing the functions to call for each case. - * @returns {Promise} A Promise that resolves to the result of the folding function. - * @example - * const asyncEither = AsyncEither.fromSync(Either.right(5)); - * const result = await asyncEither.fold({ - * ifRight: value => `Success: ${value}`, - * ifLeft: error => `Error: ${error}` - * }); - * console.log(result); // 'Success: 5' + * @template R The type of the right value. + * @template U The type of the new left value. + * @param {(value: L) => AsyncRailway | Railway} transform The transformation function. + * @returns {AsyncEither} A new AsyncEither instance containing the result of the transformation. */ - async fold(folding: FoldingEither): Promise { - const either = await this.promise; + orElse(transform: (value: L) => AsyncEither | Either): AsyncEither { + return this.flatMapLeft(transform); + } + + /** + * Applies the appropriate function from the folding object based on whether the Either resolves to a Left or Right. + * @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; return either.fold(folding); } + /** + * Adds a timeout to this AsyncEither. + * @param {number} ms Timeout in milliseconds. + * @param {() => L} onTimeout Function that returns the error value when timeout occurs. + * @returns {AsyncEither} A new AsyncEither with timeout configured. + */ + withTimeout(ms: number, onTimeout: () => L): AsyncEither { + return new AsyncEither( + Promise.race([ + this.promise, + new Promise>((resolve) => setTimeout(() => resolve(Either.left(onTimeout())), ms)), + ]) + ); + } + /** * Creates an AsyncEither from a Promise, handling any rejections with the provided function. * @template L The type of the left value. diff --git a/src/railway/async-railway.test.ts b/src/railway/async-railway.test.ts new file mode 100644 index 0000000..0b6f352 --- /dev/null +++ b/src/railway/async-railway.test.ts @@ -0,0 +1,125 @@ +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 }) => { + const result = await railway.andThen(operation).fold({ + ifRight: (value) => `value: ${value}`, + ifLeft: (error) => error, + }); + + expect(result).toBe(expected); + } + ); + + it.each(orElseTestCases)( + 'AsyncEither $type should handle orElse operation correctly', + async ({ railway, operation, expected }) => { + const result = await railway.orElse(operation).fold({ + ifRight: (value) => `value: ${value}`, + ifLeft: (error) => error, + }); + + expect(result).toBe(expected); + } + ); + + describe('withTimeout', () => { + it('should resolve normally when completing before timeout', async () => { + const asyncEither = AsyncEither.fromSafePromise(Promise.resolve(2)); + const timeoutEither = asyncEither.withTimeout(100, () => 'Timeout error'); + + const result = await timeoutEither.fold({ + ifRight: (value) => `value: ${value}`, + ifLeft: (error) => error, + }); + + expect(result).toEqual('value: 2'); + }); + + it('should trigger timeout when operation takes too long', async () => { + vi.useFakeTimers(); + + // Create a slow AsyncEither that resolves after 200ms + const slowPromise = new Promise((resolve) => setTimeout(() => resolve(42), 200)); + const slowAsyncEither = AsyncEither.fromPromise(slowPromise, (error) => String(error)); + + const timeoutEither = slowAsyncEither.withTimeout(50, () => 'Timeout error'); + + // Advance timers and wait for resolution + const resultPromise = timeoutEither.fold({ + ifRight: (value) => `value: ${value}`, + ifLeft: (error) => error, + }); + + vi.advanceTimersByTime(100); + const result = await resultPromise; + + expect(result).toEqual('Timeout error'); + + vi.useRealTimers(); + }); + }); +}); + +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', + railway: AsyncEither.fromSafePromise(Promise.resolve(2)), + operation: (x: number) => AsyncEither.fromSafePromise(Promise.resolve(x * 2)), + expected: 'value: 4', + }, + { + type: 'Left', + railway: AsyncEither.fromSafePromise(Promise.resolve(2)), + operation: (_: number) => + AsyncEither.fromPromise(Promise.reject('error'), (error) => error as string), + expected: 'error', + }, +]; + +const orElseTestCases = [ + { + type: 'Right', + railway: AsyncEither.fromSafePromise(Promise.resolve(2)), + operation: (_: string) => AsyncEither.fromSafePromise(Promise.resolve(42)), + expected: 'value: 2', + }, + { + type: 'Left', + railway: AsyncEither.fromPromise(Promise.reject('error'), (error) => error as string), + operation: (_: string) => AsyncEither.fromSafePromise(Promise.resolve(42)), + expected: 'value: 42', + }, +]; diff --git a/src/railway/async-railway.ts b/src/railway/async-railway.ts new file mode 100644 index 0000000..a00ba80 --- /dev/null +++ b/src/railway/async-railway.ts @@ -0,0 +1,10 @@ +import { Folding, Kind, 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 3ae7de0..97fea0a 100644 --- a/src/railway/index.ts +++ b/src/railway/index.ts @@ -1 +1,2 @@ export type { Folding, Railway } from './railway'; +export { AsyncRailway } from './async-railway'; diff --git a/src/railway/railway.test.ts b/src/railway/railway.test.ts index 49858d8..7c8636b 100644 --- a/src/railway/railway.test.ts +++ b/src/railway/railway.test.ts @@ -133,13 +133,13 @@ const orElseTestCases = [ { type: 'Try Success', railway: Try.success(2), - operation: (x: number) => Try.failure(new Error('fail')), + operation: () => Try.failure(new Error('fail')), expected: Try.success(2), }, { type: 'Try Failure', railway: Try.failure(new Error('fail')), - operation: (x: number) => Try.failure(new Error('fail')), + operation: () => Try.failure(new Error('fail')), expected: Try.failure(new Error('fail')), }, { diff --git a/src/railway/railway.ts b/src/railway/railway.ts index a38ba4a..7062d78 100644 --- a/src/railway/railway.ts +++ b/src/railway/railway.ts @@ -1,4 +1,4 @@ -type Kind = 'Option' | 'Either' | 'Try'; +export type Kind = 'Option' | 'Either' | 'Try'; type OptionFoldingKeys = ['ifSome', 'ifNone']; type EitherFoldingKeys = ['ifRight', 'ifLeft']; From fbc7a0d6c92d3e6c2ae32cf6b0eb3d562a3c37b7 Mon Sep 17 00:00:00 2001 From: Miguel Ignacio Cabrera San Gil Date: Thu, 15 May 2025 01:08:55 +0100 Subject: [PATCH 3/3] feat(docs): add railway pattern methods for Either and AsyncEither --- README.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3da3599..898a07b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This is a set of implementations of monads in TypeScript with OOP perspective. * [Mapping over an Either](#mapping-over-an-either) * [Using `flatMap` and `flatMapLeft`](#using-flatmap-and-flatmapleft) * [Using `map` and `mapLeft`](#using-map-and-mapleft) + * [Using Railway Pattern Methods](#using-railway-pattern-methods) * [Recovering from a Left value](#recovering-from-a-left-value) * [Running side effects](#running-side-effects) * [Folding an Either](#folding-an-either) @@ -21,6 +22,7 @@ This is a set of implementations of monads in TypeScript with OOP perspective. * [Asynchronous Operations (AsyncEither)](#asynchronous-operations-asynceither) * [Creating an AsyncEither](#creating-an-asynceither) * [Mapping over an AsyncEither](#mapping-over-an-asynceither) + * [Using Railway Pattern Methods with AsyncEither](#using-railway-pattern-methods-with-asynceither) * [Running side effects](#running-side-effects-1) * [Folding an AsyncEither](#folding-an-asynceither) * [Working with Promises](#working-with-promises) @@ -119,8 +121,6 @@ transform the value inside a `Left`. ```typescript import { Either } from '@leanmind/monads'; -m - const right = Either.right(42).flatMap(x => Either.right(x + 1)); // Right(43) const left = Either.left('Error').flatMapLeft(err => Either.left(`New ${err}`)); // Left('New Error') ``` @@ -134,6 +134,32 @@ const right = Either.right(42).map(x => x + 1); // Right(43) const left = Either.left('Error').mapLeft(err => `New ${err}`); // Left('New Error') ``` +#### 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. + +```typescript +import { Either } from '@leanmind/monads'; + +// Using andThen to chain operations on successful values (Right) +const right = Either.right(42) + .andThen(x => Either.right(x + 1)); // Right(43) + +// Using orElse to handle errors (Left) +const left = Either.left('Error') + .orElse(err => Either.left(`Handled: ${err}`)); // Left('Handled: Error') + +// Chaining operations with Railway methods +const result = Either.right(42) + .andThen(x => { + if (x > 40) { + return Either.right(x + 1); + } + return Either.left('Value too small'); + }) + .orElse(err => Either.left(`Error: ${err}`)); // Right(43) +``` + #### Recovering from a Left value You can use the `recover` method to recover from a `Left` value and transform it into a `Right`. @@ -325,6 +351,57 @@ const asyncMapped = await AsyncEither.fromSync(Either.right(42)) }); // AsyncEither ``` +##### Using Railway Pattern Methods with AsyncEither + +Similar to synchronous Either, AsyncEither also supports Railway-oriented programming with `andThen` and `orElse` methods: + +```typescript +import { AsyncEither, Either } from '@leanmind/monads'; + +// Using andThen with AsyncEither +const result = await AsyncEither.fromSync(Either.right(42)) + .andThen(x => AsyncEither.fromSync(Either.right(x + 1))) + .fold({ + ifRight: x => `Result: ${x}`, + ifLeft: err => `Error: ${err}` + }); // 'Result: 43' + +// Using orElse to handle errors in async processing +const handleError = await AsyncEither.fromSync(Either.left('Network error')) + .orElse(err => AsyncEither.fromSync(Either.right(`Recovered from ${err}`))) + .fold({ + ifRight: x => `Success: ${x}`, + ifLeft: err => `Failed: ${err}` + }); // 'Success: Recovered from Network error' + +// Real-world example with API call +async function fetchUserData(userId: string) { + return AsyncEither.fromPromise( + 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`)); + }); +} +``` + ##### Running side effects While not explicitly shown in the provided code, you can use the `fold` method with appropriate handlers to perform side