Open ptpaterson opened 3 years ago
I would love fmap for js; I would love a proper algebraic data types setup for js even more!
As you have noticed, another good name for ferrum might be haskelllish-js! Besides the name being to cumbersome and the obvious PR advantages, there is another reason why it's not called that: Ferrums soundness goals. This is really one of the main features from rust ferrum tries to introduce: Every feature should be hard to abuse and should be relatively edge case free. You can see this in functions like type() or typename() which explicitly deal with null. Or in trySomething
styled function which take a default parameter, making you choose which value to return instead of just returning null or undefined (enabling the None = Symbol(); v = trySomething(param, None); if (v === None) …
error handling idiom.
There was a fairly decent attempt utilizing coroutines to introduce something akin to the do …
notation from haskell: https://medium.com/flock-community/monads-simplified-with-generators-in-typescript-part-1-33486bf9d887
The approach works, but suffers from soundness issues which I outlined here: https://gist.github.com/koraa/78ee4620a5842ede4c62dd65708326c7
I also have reservations to just introducing fmap; it's existence has to be explained to users (why not unify with map() – this is a valid concern in haskell too where the difference between map and fmap is historic as it is here), it also wouldn't be very complete (you got .then but what about .catch? another trait for that?). Should map()
try conversion to iterator first or use fmap()
directly? Maybe would represent yet a fourth way of representing null-results (besides the null, undefined and the None-idiom). JS devs would as: "But what is wrong with null". The answer is a lot, but it is hard to explain and confusing to integrate with null/undef. Why introduce Either? JS is duck typed? I see the appeal, most js devs wont.
Ferrum comes with ifdef(val?, (v) => v)
and isdef(val?)
which are useful for dealing with null/undefined in pipes which substitute for Maybe. We could also provide default(val?, def), defaultWith(val?, () => def)
– replacing null with a default value. I am considering then(Promise, fn), reject(Promise, fn), finally(Promise, fn)
as curryable functions on promises, as well as resolve(val)
as a free-standing promise constructor. Potentially also free standing all()
and race()
functions.
Note these soundness/clarity issues shouldn't stop you from implementing a functor trait. It is perfectly feasible with ferrum, just hard to communicate…
I am planning however to introduce a foundational sequence trait, probably using coroutines; there are a lot of open questions and I am not even sure it is possible, but I could imagine this might work for some monadic types too…
const Begin = Tuple("Begin", seq);
const Gather = Tuple("Gather", it);
const Provide = Tuple("Provide", val);
const EndOfSequence = Tuple("EndOfSequence");
// Implement for sequence/iterator AND async sequence; extendable for reactive/event streams
const AbstractSequence = new Trait("AbstractSequence");
// Implement methods
const map = abstractGenerator(function*(seq, fn) {
const it = yield Begin(seq);
while (true) {
// How does that even work?
const v = yield Gather(it);
if (v === EndOfSequence) break;
yield Provide(fn(v));
}
});
const filter = abstractGenerator(function*(seq, fn) {
const it = yield Begin(seq);
while (true) {
// How does that even work?
const v = yield Gather(it);
if (v === EndOfSequence) break;
if (fn(v)) yield Provide(it);
}
});
And some very limited control flow for pipe()
would be nice so you could pipe(…, await, …)
and pipe(…, ifdef, …)
.
Thank you for the very detailed response!
Ferrum comes with ifdef(val?, (v) => v) and isdef(val?) which are useful for dealing with null/undefined in pipes which substitute for Maybe.
This is a very good point. I am a fan of composing curried and point free functions. This is nice and lightweight -- handles nicely without the extra baggage of mimicking pattern matching.
I am considering then(Promise, fn), reject(Promise, fn), finally(Promise, fn) as curryable functions on promises
Also a fascinating idea.
... implementing a functor trait. It is perfectly feasible with ferrum, just hard to communicate…
Yes. And the deeper I go down the rabbit hole, I can see how much deeper it is :)
I've been trying a few different things, and I've gotten a bit stuck on the convention that creating a new Trait only provides a single invoke
function. Some traits require implementation of multiple functions. To invoke a trait, though, only provides a single function per implementation. This is okay for those Traits that only require one function, but is confusing for those with more. A ferrum Trait, is less like a Rust trait, and more like a more specific contract on a single behavior.
To combine the concept of Sequence
's map and a functor's fmap, I don't think one should create a ferrum Functor
trait. Each implementation actually needs it's own definition of map
/fmap
, so the ferrum Trait should maybe be more like a specific FMap
trait. This is kinda trivial for just Functor, so consider Bifunctor instead, where bimap
, first
, and second
, etc. functions could all be defined for each implementation.
So, one could take the path of creating BiMap
, First
, and Second
ferrum Traits. This technique is kind of embodied in the stdtrait.js
definitions. This does sound counter to the idea of a "Trait", but it makes using this library very straightforward.
On the other hand, I've been looking at how a trait could be implemented by providing a thunk to an object with several function definitions, so that multiple function definitions must be provided at the same time.
Also of note: I love the way implementations can be derived! If there were an fmap
/map
Trait, than it could be implemented automatically for Sequence
(though I have not exhaustively tested it).
// FMap Trait Definition
// Could be called "Mappable" instead? for the hardcore FP uninitiated?
const FMap = new Trait('FMap')
const fmap = curry('fmap', (what, f) => FMap.invoke(what, f))
// Derive FMap for Sequence
FMap.implDerived([Sequence], function* ([iter], seq, f) {
for (const val of iter(seq)) {
yield f(val)
}
})
const sequenceTest = pipe([1, 2, 3, 4], fmap(plus(10)), list)
// [11, 12, 13, 14]
easy enough to implement for a trivial Option type, for example
// Option
const Some = function (value) {
this.value = value
}
const None = function () {}
const none = new None()
FMap.impl(Some, (what, f) => new Some(f(what.value)))
FMap.implStatic(none, () => none)
const someTest = pipe(new Some(2), fmap(plus(4))
)
// Some { value: 6 }
const noneTest = pipe(none, fmap(plus(4)))
// None { }
For a bifunctor, I tried tried out implementing multiple functions under a single trait. While it does the job, it's not very nice, and I'm sure I am breaking the ability to do other things within ferrum.
// Bifunctor
const Bifunctor = new Trait('Bifunctor')
const bimap = curry('bimap', (what, f, g) => Bifunctor.invoke(what).bimap(f, g))
const first = curry('first', (what, f) => Bifunctor.invoke(what).first(f))
const second = curry('second', (what, g) => Bifunctor.invoke(what).second(g))
// Result
const Ok = function (value) {
this.value = value
}
const NotOk = function (value) {
this.value = value
}
FMap.impl(Ok, (what, f) => new Ok(f(what.value)))
Bifunctor.impl(Ok, (what) => ({
bimap: (f, g) => new Ok(f(what.value)),
first: (f) => new Ok(f(what.value)),
second: (g) => new Ok(what.value),
}))
FMap.impl(NotOk, (what, f) => new NotOk(what.value))
Bifunctor.impl(NotOk, (what) => ({
bimap: (f, g) => new NotOk(g(what.value)),
first: (f) => new NotOk(what.value),
second: (g) => new NotOk(g(what.value)),
}))
const resultTester = (x) => pipe(x,
bimap(plus(5), (e) => `Major ${e}`),
first(plus(40)),
second((e) => `${e}, Seriously`)
)
const okTest = resultTester(new Ok(5))
// Ok { value: 50 }
const notOkTest = resultTester(new NotOk('Error'))
// NotOk { value: 'Major Error, Seriously' }
I'll also say that going into this, I did not realize that Rust does not define functor/monad operations through traits. Where there is overlap, the types just implement similar functions based on convention. Rust comes out okay without a generic fmap.
I am thinking of dropping an concern for implementing traits for functors, et al.. But I still love how traits can be used for all the other use cases!
Fascinating! Thank you for sharing the code!
The idiom you are using – returning some object that implements an interface – is exactly what I would recommend for implementing more complex interfaces. It's the same idiom used by the iterator protocol.
Be careful with trait derivations; they are kind of icky, using a O(n)
lookup…not a problem if you just have a small number of derivations though.
Btw, your named tuple implementation suffers from some drawbacks…e.g. will act weirdly if not invoked with new…which is why deferred to some "imaginary" future tuple constructor. If you want tuples now I recommend something like this:
function Ok(value) {
if (isdef(this) || type(this) !== Ok)
return new Ok(v);
Object.assign(this, { value });
}
Ok.protoype[Symbol.iterator] = () => iter([this.value]); // Destructuring
Just interested: What are your thoughts on https://github.com/adobe/ferrum/issues/138?
Overview
This looks awesome, and makes me want to bring out the whole FP toolkit to use along with ferrum. I'm looking for thoughts and/or best practices for using ferrum with non-sequence objects, too. I.e. more general functors and monads.
Details
The README and project layout make it abundantly clear that the focus is on improve working with iterators. Also, the introduction video makes jokes (which I thoroughly enjoy) when the word "monad" is brought up that no one should have to worry about that means. Still, I am curious what thoughts there are to supporting the more mathematical basis for some of these traits, or at least using this library with other sources that do assume that.
For example, if I start using ferrum, it's only a matter of time before I will want to use some version of type classes for things like Option/Maybe, Either, Result, etc. All of these things should have a
map
operation.map
is hardcoded as applying a function to a sequence, so it cannot be overridden (overloaded? anyway...) with the implementation for non-sequence functors.I think what I would do is start making a separate local lib with a
Functor
Trait and define anfmap
function for it as the basis for other things.That said, I think that the
Sequence
trait could be refactors to be based on aFunctor
trait. I honestly cannot foresee the fallout of that, but I know it would at least make this library significantly more complex. Not least of all because one can go nuts and add inBiFunctor
andApplicative
and.... ooh boy then we have all of Haskell now wrapped up in this bloated library... But seriously, I would just as quickly be looking forfmap_left
andchain
functions as anything else... 🤓 😋Bonus points for any concrete examples of tying ferrum in with any other fp libraries.
Cheers! And thank you!