Having been introduced to monads through Scala, I am trying to add the generalized design pattern to my toolkit by implementing it in a language I use more regularly (typescript).
My tests pass (bottom snippet), but I worry that I am committing heresies and/or doing it wrong. Could anyone suggest improvements?
I'll post code here, but the source is accessible and easily runnable/testable on branch stackExchangeCodeReview
of this repo (requires node).
I have a base class, Monad<T>
...
export type mapping<T, U> = (x: T, ...args: any[]) => U;
/**
* Describes the base monadic operations and holds state in two properties:
*
* `_wrapped` is the monadic container, managed in sub-classes
*
* `_unwrapped` is the original value passed to the constructor (unit). This is included for convenience, since
* the `_wrapped` value is not guaranteed to preserve the monad's input, and it could be difficult to
* functionally map back to that input.
*/
export abstract class Monad<T> {
public abstract wrapped: any;
public abstract unit: <T>(x: T, ...args: any[]) => Monad<T>;
public abstract flatMap: <U>(f: mapping<T, Monad<U>>) => Monad<U>;
private _unwrapped: T;
public get unwrapped(): T {
return this._unwrapped;
}
constructor (x: T, ...args: any[]) {
this._unwrapped = x;
}
}
... which is used to create a sub-class Logger<T>
, which performs operations and keeps a log of those operations. (inspired by this JS implementation of Haskell's writer monad)
import {Monad, mapping} from './monad';
/**
* Keeps a log of operations and their return values by accepting labels for lifted operations.
*/
interface ILog <T> {
value: T;
log: string;
}
export class Logger<T> extends Monad<T> {
private _wrapped: ILog<T>;
public get wrapped(): ILog<T> {
return this._wrapped;
}
public static lift = <T, U>(f: mapping<T, U>, label: string) => (x: T, log: string) => new Logger(f(x), log + '\n' + label);
constructor (x: T, log: string = 'unit') {
super(x);
this._wrapped = { value: x, log: `${log} -> ${x}` };
}
public unit = (x: T, log: string) => new Logger(x, log);
public flatMap = <U>(f: mapping<T, Logger<U>>) => f(this.unwrapped, this.wrapped.log);
}
It can be used like this...
import {Logger} from './monads/logger';
const double = (n: number) => n * 2;
const square = (n: number) => n * n;
const gtTen = (n: number) => n > 10;
const lDouble = Logger.lift(double, 'double');
const lSquare = Logger.lift(square, 'square');
const lGtTen = Logger.lift(gtTen, 'greater than ten');
const chain = new Logger(2).flatMap(lDouble).flatMap(lSquare).flatMap(lGtTen);
console.log(chain.wrapped.log, '\n\nwrapped.value: ', chain.wrapped.value, '\n\nunwrapped: ', chain.unwrapped);
// unit -> 2
// double -> 4
// square -> 16
// greater than ten -> true
//
// wrapped.value: true
//
// unwrapped: true
... and I am testing that it fulfills the monadic contract in these passing tests:
import {mapping} from './monad';
import {Logger} from './logger';
describe('logger', () => {
it('should initialize', () => {
expect(Logger).toBeDefined();
});
it('should fulfill left unit law: `unit(x).flatMap(f) == f(x)`', () => {
const x = 1;
const f: mapping<number, Logger<number>> = (n: number) => new Logger(n);
const leftSide = new Logger(x).flatMap(f);
const rightSide = f(x);
const expected = jasmine.objectContaining({
unwrapped: rightSide.unwrapped,
wrapped: rightSide.wrapped
});
expect(leftSide).toEqual(expected);
});
it('should fulfill right unit law: `unit(x).flatMap(unit) == unit(x)`', () => {
const x = 1;
const leftSide = new Logger(x);
const rightSide = leftSide.flatMap(leftSide.unit);
const expected = jasmine.objectContaining({
unwrapped: rightSide.unwrapped,
// Logs in `wrapped` will not match since left and right side go through different operations
// wrapped: rightSide.wrapped
});
expect(leftSide).toEqual(expected);
});
it('should fulfill law of associativity: `unit(x).flatMap(f).flatMap(g) == unit(x).flatMap(x => f(x).flatMap(g))`', () => {
const x = 1;
const f: mapping<number, Logger<number>> = (n: number) => new Logger(n, 'f');
const g: mapping<number, Logger<number>> = (n: number) => new Logger(n + 2, 'g');
const m = new Logger(x);
const leftSide = m.flatMap(f).flatMap(g);
const rightSide = m.flatMap(num => f(num).flatMap(g));
const expected = jasmine.objectContaining({
unwrapped: rightSide.unwrapped,
wrapped: rightSide.wrapped
});
expect(leftSide).toEqual(expected);
});
it('should keep a log of all operations and return values', () => {
const double = (n: number) => n * 2;
const square = (n: number) => n * n;
const gtTen = (n: number) => n > 10;
const lDouble = Logger.lift(double, 'double');
const lSquare = Logger.lift(square, 'square');
const lGtTen = Logger.lift(gtTen, 'greater than ten');
const chain = new Logger(2).flatMap(lDouble).flatMap(lSquare).flatMap(lGtTen);
expect(chain.wrapped.value).toBe(true);
expect(chain.wrapped.log).toBe('unit -> 2\ndouble -> 4\nsquare -> 16\ngreater than ten -> true');
});
});