Skip to content

Commit

Permalink
feat: add maybeT monadic transformer
Browse files Browse the repository at this point in the history
Generally, a monadic transformer allows one to merge two monads into
a single tool with the traits of both parent monads. In the case of the
`maybe` monad, the resultant tool will behave with maybe semantics: if
the contained value is null then the `map` function will not be called,
else it will.

When combined with an array (which is monadic in a sense), then the
`maybeT` transform will iterate the `map` function over each element of
the array and call the `map` function only if the element is non-null.

This change adds special handling for `Promises` - or more generically,
thenables - and treats the `then` function as a `map` function. This
isn't technically true, since `then` is actually a `flatMap` function
but these pedantics are relatively minor. When `maybeT` is combined with
`Promise`, the `map` function will be called once the promise resolves
if the resolve value is not nullable.
  • Loading branch information
andy.patterson authored and andnp committed May 8, 2018
1 parent 81cec04 commit dd50807
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 0 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,8 @@
},
"release": {
"branch": "master"
},
"dependencies": {
"simplytyped": "^1.0.5"
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ Maybe[fl.of] = maybe;

export { some } from './some';
export { none } from './none';
export { maybeT, MaybeT } from './transformer';

export default Maybe;
66 changes: 66 additions & 0 deletions src/transformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ConstructorFor, Unknown, Nullable } from 'simplytyped';
// @ts-ignore
import Maybe, { MatchType, Nil } from './maybe';
import { maybe } from './index';

export interface Monad<T> {
map: <U>(f: (v: T) => Nullable<U>) => any;
}

export type MonadLike<T> = Monad<T> | PromiseLike<T>;
export type MonadValue<T extends MonadLike<any>> =
T extends PromiseLike<infer U> ? U :
T extends Monad<infer U> ? U : never;

export type MaybeValue<T extends MonadLike<any>> = NonNullable<MonadValue<T>>;

const isPromise = (x: any): x is PromiseLike<any> => typeof x.then === 'function';

const getMap = (x: MonadLike<any>): Monad<any>['map'] => {
if (isPromise(x)) return x.then.bind(x) as any;

return x.map.bind(x) as any;
};

export class MaybeT<T extends MonadLike<Unknown>> {
private constructor(private value: T) {}

static maybeT<V extends MonadLike<Unknown>>(monad: V) {
return new MaybeT(monad);
}

map<U>(f: (v: MaybeValue<T>) => U): MaybeT<Monad<U>> {
const map = getMap(this.value);
return new MaybeT(map(inner =>
maybe(inner)
.map(f)
.asNullable() as any,
));
}

caseOf<R>(matcher: MatchType<MaybeValue<T>, R>): MaybeT<Monad<R>> {
const map = getMap(this.value);
return new MaybeT(map(inner =>
maybe(inner)
.caseOf(matcher)
.asNullable() as any,
));
}

orElse<U extends MonadValue<T>>(def: U | (() => U)): U {
const map = getMap(this.value);
return map(inner =>
maybe(inner)
.orElse(def),
);
}

asNullable() { return this.value; }
asType<M extends MonadLike<Nullable<MonadValue<T>>>>(c: ConstructorFor<M>): M {
if (!(this.value instanceof c)) throw new Error(`Expected value to be instance of monad ${c.name}`);

return this.value;
}
}

export const maybeT = MaybeT.maybeT;
102 changes: 102 additions & 0 deletions tests/transformer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { maybeT } from '../src';

test('array - can generate a maybeT', () => {
const raw = [1, 2, null, undefined, 5];
const x = maybeT(raw);
const got = x.asNullable();

expect(got).toEqual(raw);
});

test('array - can map function over non-nil elements in array', () => {
const x = maybeT([1, null, 1, undefined, 1]);

const y = x.map(v => {
expect(v).toBe(1);
return 2;
}).asNullable();

expect(y).toEqual([2, null, 2, null, 2]);
});

test('array - can get back array type from maybeT', () => {
const x = maybeT(['1', '2', null, '4']);

const y = x
.map(v => parseInt(v))
.map(v => v + 1)
.asType<Array<number | null>>(Array);

expect(y).toEqual([2, 3, null, 5]);
});

test('array - cannot get back wrong monadic type', () => {
const x = maybeT(['hi']);

expect(() => {
x.asType<Promise<string>>(Promise);
}).toThrowError();
});

test('array - can pattern match over values', () => {
const x = maybeT(['1', '2', null, '4']);

const y = x
.caseOf({
none: () => 3,
some: v => parseInt(v),
})
.asNullable();

expect(y).toEqual([1, 2, 3, 4]);
});

test('array - can default null values', () => {
const x = maybeT([1, 2, null, 4]);

const y = x.orElse(3);

expect(y).toEqual([1, 2, 3, 4]);
});

test('promise - can generate a maybeT', async () => {
const value = 'hi';
const x = maybeT(Promise.resolve(value));

const got = await x.asNullable();

expect(got).toBe(value);
});

test('promise - cannot create maybe with rejected promise', async () => {
expect.assertions(1);
const value = 'hey';
const x = maybeT(Promise.reject(value));

try {
await x.asNullable();
} catch (e) {
expect(e).toBe(value);
}
});

test('promise - can map function over non-nil value', async () => {
const x = maybeT(Promise.resolve('hey'));

const got = await x
.map(v => v + ' there')
.asType<Promise<string | null>>(Promise);

expect(got).toBe('hey there');
});

test('promise - will not map function over nil values', async () => {
const x = maybeT(Promise.resolve(null));

const got = await x
.map(v => {
throw new Error("I shouldn't be here!");
}).asNullable();

expect(got).toBe(null);
});

0 comments on commit dd50807

Please sign in to comment.