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');
});
});
1 Answer 1
export type mapping<T, U> = (x: T, ...args: any[]) => U;
export abstract class Monad<T> {
public abstract unit: (x: T, ...args: any[]) => Monad<T>;
public abstract flatMap: <U>(f: mapping<T, Monad<U>>) => Monad<U>;
}
I've dropped everything that did not match the monad typeclass members in Haskell.
- There is no need to store
wrapped
since this is already represented bythis
. - I've removed
unwrapped
. It conveys the idea that a monad always wraps a value from an underlying type. However, this artificially restricts the idea.
export class Logger<T> extends Monad<T> {
private _log: string[];
public get log(): string[] { return this._log; }
private _value: T;
public get value(): T { return this._value; }
public static liftLogger = <T2, U>(f: mapping<T2, U>, label: string) => ((x: T2) => new Logger(f(x), [label]));
constructor (x: T, log: string[] = []) {
super();
this._value= x;
this._log= log;
}
public unit = (x: T, log: string): Logger<T> => new Logger(x, [log]);
public flatMap = <U>(f: mapping<T, Logger<U>>) => {
let x = f(this.unwrapped);
return new Logger(x.unwrapped, [...this.wrapped, ...x.wrapped]);
}
}
- I've removed the
ILog<T>
interface. It stored anothervalue
that was already stored. - To match implementations with the Writer monad, I've changed the log entry type from
string
tostring[]
. lift
did not only lifted the function, it implemented the monadic bind for theLogger<T>
type. This is now done insideflatMap
.
- I've not looked at the
MList
type. - I'd like to see an implementation that did not rely on
Monad<T>
being a base class. In Haskell, a type is made a monad by creating aMonad
typeclass instance for it.
Edit 1: Rename _wrapped
-> _log
, _unwrapped
-> value
-
\$\begingroup\$ Thanks, this is great! I've skimmed and will have a closer look Sat. \$\endgroup\$Manningham– Manningham2017年07月11日 07:11:47 +00:00Commented Jul 11, 2017 at 7:11
-
\$\begingroup\$ These are some nice improvements and I've implemented and simplified the syntax a bit on this branch. I made
Monad
an interface instead of a base class, which I suppose is as close to a Haskell typeclass as we can get in Typescript. I re-implemented some logic to add the values to the operations log, since it seemed like a shame to have lost that in your revision. Thanks for your feedback! \$\endgroup\$Manningham– Manningham2017年07月17日 04:32:04 +00:00Commented Jul 17, 2017 at 4:32
Explore related questions
See similar questions with these tags.