Skip to content

Commit

Permalink
feat: add future monad
Browse files Browse the repository at this point in the history
  • Loading branch information
myugen committed Sep 10, 2024
1 parent 0a2e93f commit 825f7c7
Show file tree
Hide file tree
Showing 12 changed files with 181 additions and 1 deletion.
3 changes: 3 additions & 0 deletions src/complete/completable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Completable<T> {
complete<S>(onSuccess: (value: T) => S, onFailure: (error: Error) => S): Promise<S>;
}
1 change: 1 addition & 0 deletions src/complete/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Completable } from './completable';
19 changes: 19 additions & 0 deletions src/either/either.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { Either } from './either';
import { Option } from '../option';
import { Future } from '../future';

describe('Either monad', () => {
it.each([
Expand Down Expand Up @@ -28,6 +29,24 @@ describe('Either monad', () => {
expect(Either.from(matchable)).toEqual(expected);
});

it.each([
{
completionType: 'Success',
completable: Future.of(() => Promise.resolve(2)),
eitherType: 'Right',
expected: Either.right(2),
},
{
completionType: 'Failure',
completable: Future.of(() => Promise.reject(new Error('Error'))),
eitherType: 'Left',
expected: Either.left(new Error('Error')),
},
])('$completionType completion should create $eitherType', async ({ completable, expected }) => {
const either = await Either.complete(completable);
expect(either).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) },
Expand Down
3 changes: 3 additions & 0 deletions src/either/either.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Monad } from '../monad';
import { Matchable } from '../match';
import { Completable } from '../complete';

type AsyncEither<L, R> = Promise<Either<L, R>>;

abstract class Either<L, R> implements Monad<R>, Matchable<R, L> {
static right<T>(value: T): Either<never, T> {
Expand Down
70 changes: 70 additions & 0 deletions src/future/future.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { assert, describe, expect, it } from 'vitest';
import { Future } from './future';

describe('Future monad', () => {
it.each([
{
completionType: 'success',
action: () => Promise.resolve(2),
expected: {
ifSuccess: (value: number) => expect(value).toBe(2),
ifFailure: (_) => assert.fail('Error should not be thrown'),
},
},
{
completionType: 'failure',
action: () => Promise.reject<number>(new Error('Error')),
expected: {
ifSuccess: () => assert.fail('Value should not be emitted'),
ifFailure: (error: Error) => expect(error).toEqual(new Error('Error')),
},
},
])('should handle $completionType completion', async ({ action, expected }) => {
const future = Future.of(action);
await future.complete(expected.ifSuccess, expected.ifFailure);
});

it.each([
{
completionType: 'Success',
future: Future.of(() => Promise.resolve(2)),
expected: {
ifSuccess: (value: number) => expect(value).toBe(4),
ifFailure: (_) => assert.fail('Error should not be thrown'),
},
},
{
completionType: 'Failure',
future: Future.of(() => Promise.reject<number>(new Error('Error'))),
expected: {
ifSuccess: () => assert.fail('Value should not be emitted'),
ifFailure: (error: Error) => expect(error).toEqual(new Error('Error')),
},
},
])('$completionType completion should handle map correctly', async ({ future, expected }) => {
const actual = future.map((value) => value * 2);
await actual.complete(expected.ifSuccess, expected.ifFailure);
});

it.each([
{
completionType: 'Success',
future: Future.of(() => Promise.resolve(2)),
expected: {
ifSuccess: (value: number) => expect(value).toBe(4),
ifFailure: (error: Error) => assert.fail('Error should not be thrown'),
},
},
{
completionType: 'Failure',
future: Future.of(() => Promise.reject<number>(new Error('Error'))),
expected: {
ifSuccess: () => assert.fail('Value should not be emitted'),
ifFailure: (error: Error) => expect(error).toEqual(new Error('Error')),
},
},
])('$completionType completion should handle flatMap correctly', async ({ future, expected }) => {
const actual = future.flatMap((value) => Future.of(() => Promise.resolve(value * 2)));
await actual.complete(expected.ifSuccess, expected.ifFailure);
});
});
24 changes: 24 additions & 0 deletions src/future/future.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Monad } from '../monad';
import { Completable } from '../complete';

class Future<T> implements Monad<T>, Completable<T> {
private constructor(private readonly action: () => Promise<T>) {}

static of<T>(action: () => Promise<T>): Future<T> {
return new Future(action);
}

map<U>(transform: (value: T) => U): Future<U> {
return new Future<U>(() => this.action().then(transform));
}

flatMap<U>(transform: (value: T) => Future<U>): Future<U> {
return new Future<U>(() => this.action().then((value) => transform(value).action()));
}

complete<S>(onSuccess: (value: T) => S, onFailure: (error: Error) => S): Promise<S> {
return this.action().then(onSuccess).catch(onFailure);
}
}

export { Future };
1 change: 1 addition & 0 deletions src/future/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Future } from './future';
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './either';
export * from './future';
export * from './option';
export * from './try';
19 changes: 19 additions & 0 deletions src/option/option.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { None, Option, Some } from './option';
import { Either } from '../either';
import { Future } from '../future';

describe('Option monad', () => {
it.each([
Expand All @@ -22,6 +23,24 @@ describe('Option monad', () => {
expect(Option.from(matchable)).toEqual(expected);
});

it.each([
{
completionType: 'Success',
completable: Future.of(() => Promise.resolve(2)),
optionType: 'Some',
expected: Option.of(2),
},
{
completionType: 'Failure',
completable: Future.of(() => Promise.reject(new Error('Error'))),
optionType: 'None',
expected: Option.of<number>(undefined),
},
])('$completionType completion should create $optionType', async ({ completable, expected }) => {
const option = await Option.complete(completable);
expect(option).toEqual(expected);
});

it.each([
{ type: 'Some', option: Option.of(2), expected: 2 },
{ type: 'None', option: Option.of<number>(undefined), expected: 2 },
Expand Down
12 changes: 11 additions & 1 deletion src/option/option.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Nullable } from '../types';
import { Monad } from '../monad';
import { Matchable } from '../match';
import { Completable } from '../complete';

type AsyncOption<T> = Promise<Option<T>>;

abstract class Option<T> implements Monad<T>, Matchable<T, undefined> {
static of<T>(value: Nullable<T>): Option<T> {
Expand All @@ -17,6 +20,13 @@ abstract class Option<T> implements Monad<T>, Matchable<T, undefined> {
);
}

static complete<T>(completable: Completable<T>): AsyncOption<T> {
return completable.complete<Option<T>>(
(value: T) => Option.of(value),
() => Option.of<T>(undefined)
);
}

abstract getOrElse(otherValue: T): T;

abstract filter(predicate: (value: T) => boolean): Option<T>;
Expand Down Expand Up @@ -96,4 +106,4 @@ class None<T> extends Option<T> {
}
}

export { Option, Some, None };
export { Option, Some, None, AsyncOption };
19 changes: 19 additions & 0 deletions src/try/try.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { Failure, Success, Try } from './try';
import { Either } from '../either';
import { Option } from '../option';
import { Future } from '../future';

describe('Try monad', () => {
it.each([
Expand Down Expand Up @@ -41,6 +42,24 @@ describe('Try monad', () => {
expect(Try.from(matchable)).toEqual(expected);
});

it.each([
{
completionType: 'Success',
completable: Future.of(() => Promise.resolve(2)),
tryType: 'Success',
expected: new Success(2),
},
{
completionType: 'Failure',
completable: Future.of(() => Promise.reject(new Error('Error'))),
tryType: 'Failure',
expected: new Failure(new Error('Error')),
},
])('$completionType completion should create $tryType', async ({ completable, expected }) => {
const actual = await Try.complete(completable);
expect(actual).toEqual(expected);
});

it.each([
{ type: 'Success', tryMonad: Try.execute(() => 2), expected: false },
{
Expand Down
10 changes: 10 additions & 0 deletions src/try/try.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Monad } from '../monad';
import { Matchable } from '../match';
import { Completable } from '../complete';

type AsyncTry<T> = Promise<Try<T>>;

abstract class Try<T> implements Monad<T>, Matchable<T, Error> {
static execute<T>(executable: () => T): Try<T> {
Expand All @@ -17,6 +20,13 @@ abstract class Try<T> implements Monad<T>, Matchable<T, Error> {
);
}

static complete<T>(completable: Completable<T>): AsyncTry<T> {
return completable.complete<Try<T>>(
(value: T) => new Success(value),
(error: Error) => new Failure(error)
);
}

abstract map<U>(transform: (value: T) => U): Try<U>;

abstract flatMap<U>(transform: (value: T) => Try<U>): Try<U>;
Expand Down

0 comments on commit 825f7c7

Please sign in to comment.