monio icon indicating copy to clipboard operation
monio copied to clipboard

Reviewing Monio's design/behavior (formerly: Typescript Support)

Open Eyal-Shalev opened this issue 3 years ago • 47 comments

Do you have any plans to convert the code to typescript? Or add .d.ts files?

Eyal-Shalev avatar Jan 20 '21 21:01 Eyal-Shalev

I do not have any plans to convert to TS. Internally, there are so many layers of value polymorphism to make it both lawful and ergonomic, it seems like it would a real challenge to type all those boundaries. Just curious: how would the implementation being in TS or not affect users of the library?

However, I would entertain .d.ts type bindings. Though I think by nature of this library relying heavily on iterators, it may be challenging to achieve much in the way of narrow typing. But it may be worth the effort. I don't plan to create them myself as I'm not a TS adopter, but I would happily consider a contribution of such. Interested?

getify avatar Jan 21 '21 00:01 getify

I've forked the project and started working on the conversion (in my free time). But I'm currently labeling the conversion as a pet project, so don't assign it to me just yet 😅

Eyal-Shalev avatar Jan 21 '21 08:01 Eyal-Shalev

@getify I'm working on the implementation, but I don't understand the purpose of the concat method, as it is implemented in the Just monad (for simplicity).

Why does just.of([1, 2]).concat([3]) equal [[1,2,3]]
Shouldn't it equal to Just([1,2,3])?

In other words:

  1. Why does concat returns a non-monad?
  2. Why does concat internally call concat on each item of the received array?

P.S. Your implementation is not consistent. The concat method on Nothing will return a monad (Nothing), but on Just it returns a value.

Eyal-Shalev avatar Jan 22 '21 16:01 Eyal-Shalev

@getify Also, what is the purpose of the _is function and the brand variable?

Eyal-Shalev avatar Jan 22 '21 16:01 Eyal-Shalev

Thanks for looking into this effort! Appreciate the help. :)

but I don't understand the purpose of the concat method

Summoning @drboolean for further comment if I'm unclear in any of my answers here.

The concat(..) method is implementing the so called "concatable" behavior, or in more formal terms, semigroup. Some of the monads in Monio support this, where it makes sense.

Why does just.of([1, 2]).concat([3]) equal [[1,2,3]] Why does concat returns a non-monad?

As far as I can tell, that's a violation of the lawful usage, because Just.concat(..) expects a Just monad, and you provided it an array (which happens to have a map(..) call on it, but is not a conforming monad). Try this:

Just.of([1,2]).concat(Just.of([3])._inspect();  // Just([1,2,3])

Why does concat internally call concat on each item of the received array?

That's what concatable/semigroup does... it sorta delegates to an underlying value's concat(..) method (assuming that concat(..) method conforms to the expected behavior), which JS arrays and strings happen to have.

It only appears to be calling against the items of the array because you passed an array to concat(..) instead of another concatable monad (like Just). Otherwise, that .map(..) call you see would have been against the monad, not against an array of values.

I think another way of saying this is: unfortunately, while JS arrays (and strings) are concatable/semigroups, they're not conforming monads.

P.S. Your implementation is not consistent. The concat method on Nothing will return a monad (Nothing), but on Just it returns a value.

I indeed may have mistakes in Monio, I didn't claim it's perfect. However, in this case, I think you'll find it's consistent when the methods are used lawfully. That is, methods like chain(..), map(..), and concat(..), when called on any of Monio's monads, and when used properly/lawfully, should always return a monad of the same type as the method was called on.

Also, what is the purpose of the _is function and the brand variable?

All of the monads in Monio expose public is(..) and _inspect(..) methods for convenience and debugging purposes. It's likely rare that you'll interact with them in actual application code.

_is(..) (which is part of the public is(..) method's behavior) helps facilitate boolean "identity" checks on values, using the internal-only brand variable that's unforgeable. So Just.is(v) will only ever return true if v was created as a Just by Monio. The _is(..) function is internal only, and used as part of the cascading identity-check mechanics that need to happen for things like Maybe.is(v) delegating to Just.is(v), as well as for the _inspect() (which is public despite its _ prefix) serialization of values.

getify avatar Jan 22 '21 17:01 getify

I got confused because of the test in just.test.js:

qunit.test("#concat", (assert) => {
	assert.deepEqual(
		just.of([1, 2]).concat([3]),
		[[1, 2, 3]],
		"should concat a just array to an array"
	);
});

Eyal-Shalev avatar Jan 22 '21 18:01 Eyal-Shalev

Sorry, that's a mistake... those tests came from another contributor and I didn't look closely enough.

getify avatar Jan 22 '21 18:01 getify

👋 Nailed it

DrBoolean avatar Jan 22 '21 19:01 DrBoolean

@getify After a few months of hiatus, I returned to this conversion and had some more questions:

  1. What is the purpose of the fold method? I see that it is defined on Either, AsyncEitehr & Maybe.
  2. The ap method on IO takes a Monad and calls the map method on said monad with the function in the IO. That means that the ap method in IO will return a (maybe promise) monad. Why is that?
  3. It seems like Nothing / Just monads are very similar to Left / Right monads. Is there any reason not to implement Nothing / Just on top of Left / Right?

Eyal-Shalev avatar Apr 09 '21 10:04 Eyal-Shalev

Thanks for picking back up the effort, welcome back! :)

Your questions all directly flow from type/category theory -- that is, none of that is particularly specific to Monio. I'm no expert on the topic, but I think I'm competent enough to give it a stab (again, with review from @drboolean).

Brace yourself, because this is a looooong explanation. :)

What is the purpose of the fold method? I see that it is defined on Either, AsyncEitehr & Maybe.

fold(..) is the so-called "foldable" behavior, which in these cases is taking a "sum type" (i.e., a monad holding one of two or more values) and "folding" that down to a single value. It's sort of like reduce(..) on an array, which is often called foldL(..) in FP speak.

As applied to Either and Maybe, it's essentially reducing (or, unwrapping) the duality choice inherent in those monads, such that you get the underlying value. It does so by "deciding" which of two functions to invoke (and provide the value). For Maybe, it invokes the first function in the case of Maybe:Nothing, and the second function in the case of Maybe:Just. For Either, it invokes the first function in the case of Either:Left and the second function in the case of Either:Right.

This function is somewhat special (in my perspective) in that the end result of calling it is extracting the underlying value, as opposed to requiring that it produce another Monad of the same kind as fold(..) was invoked on, the way chain(..), map(..), and others do.

The ap method on IO takes a Monad and calls the map method on said monad with the function in the IO... Why is that?

The ap(..) method is "applicative". It's a bit of a strange one, but you'll notice it's on all the monads, not just IO. The purpose of it is when your monad is holding a unary function (only expecting a single input argument), and you want to "apply" the value of another monad as the input to that function, and wrap that result back in the monad.

It's perhaps more easily illustrated in code, like this (using a curried sum(..) function):

const sum = x => y => x + y;

var threePlus = Just( sum(3) );  // Just( y => x + y ) ... IOW, a Just holding a unary function

var four = Just(4);  // a Just holding a single numeric value

var seven = threePlus.ap( four );  // Just(7)

// which is the same as (the not quite lawful but still illustrative):

var three = Just(3);
var seven = four.map( three.chain(sum) );  // Just(7)

That last line isn't "lawful" in that the three.chain(..) method is supposed to be provided a function that will return another Just monad, but here it's returning a curried function instead (holding the inner 3 value via closure), which is then passed as the unary mapper function to four.map(..), resulting in the Just(7) value.

Since that kind of transformation is possible but not lawful, the ap(..) method provides a lawful way to accomplish it instead.

To your original question, ap(..) might seem a natural fit for an IO monad, since IO monads always already hold functions. However, the use of that might be a fair bit unintuitive, since effectively, the function held in one IO is passed in as the argument to the other IO's held function. IOW, the function held in the IO that you're going to invoke ap(..) on has to be a higher-order function (HOF) that can receive another function as input.

Moreover, since the functions in IOs are for the laziness of IO, and thus aren't typically part of the operations/transformations themselves, to make ap(..) work, the function wrapping is going to be awkward from what I can tell.

var x = IO( () => v => v * 3 );  // IO holding a simple unary function
var y = IO( fn => fn(7) * 2 );  // IO as a HOF

var z = y.ap(x);  // IO essentially holding the lambda function () => 42
z.run();  // 42

Actually, TBH, I'm not even positive IO's implementation of ap(..) is correct, since I personally haven't found any use for it. That code works, but it seems weird to me (notice the difference in extra lambda wrapping on one but not the other). I just included ap(..) with IO for completeness sake, since it's already on the other monads.

That means that the ap method in IO will return a (maybe promise) monad.

Not quite. Again, the "lawful" use of ap(..) is that it must be called with a monad argument of the same kind as the monad you invoke ap(..) on -- so IO.ap(IO) or Just.ap(Just). You're not supposed to do Just.ap(Maybe) or some other mixture like that.

It seems like Nothing / Just monads are very similar to Left / Right monads

Similar, yes. Equivalent (interchangable)? No.

Is there any reason not to implement Nothing / Just on top of Left / Right?

I think that would be challenging/unwise for a few reasons, mostly implementation related to Monio, but I think in theory it would be possible to do so lawfully, though you couldn't do so the other way around (since Nothing is lossy -- holds no value -- and Left holds a distinct value).

One challenge is the internal type (aka "kind") checking I do with is(..), and the cascading that it does so that Maybe.is( Just(3 ) returns true as well as Maybe.is( Maybe.from(3) ). Those are technically different kinds of values, but I pave over the difference ergonomically, and thus I ensure the is(..) type-kind checking reflects that intention.

That's mostly done because typically Just and Nothing are not implemented as separate standalone monads, but rather as directly tied to Maybe. Monio did that because it makes illustrating monad concepts easy when you have, essentially, the identity monad with Just(..), and I didn't want a whole separate identity monad that completely duplicated everything I did for Just(..).

OTOH, Either:Left / Either:Right are deeply tied to Either, and don't exist in Monio standalone. So I can't really see how Just / Nothing could be cleanly implemented on top of them, without separating them from Either -- which there's no real reason to do, since they're not particularly useful as standalone monads.

Also, the Nothing monad is different from the Either:Left monad, from a performance perspective, because its short-circuiting is to always return the public Nothing interface (since we don't need to hold any instance value in closure). By contrast, the short-circuiting that Either:Left does is instance-sensitive (which is slightly less performant than what Nothing does).


(deep breath) Phew, hope that was helpful. Hope I didn't make things worse by confusing or overwhelming the situation.

Bottom line: just about everything you'll encounter in Monio's monads is there for a reason -- generally for some aspect of type or category theory. Only some of that space is personally in my comfort zone, but Monio is designed to pull together a lot of those things into a set of all-powerful monadic goodness. ;-)

getify avatar Apr 09 '21 12:04 getify

I should also mention that while I endeavor to maintain "lawful" uses and avoid "unlawful but possible" uses, internally there are definitely cases where I intentionally violate the laws because it's more performant or more convenient with my implementation. For example, the _inspect() function basically needs to violate the law by calling chain(v => v) to "extract" a value to display in the serialized output.

My point is, while the TS typings can be aware of these lawful vs unlawful usages, we shouldn't design them such that they restrict such usages.

getify avatar Apr 09 '21 12:04 getify

That was helpful. I had a hunch on most of the stuff you explained, but wanted to make sure instead of making assumptions.

Putting aside my TS implementation I think you can simplify the inspect function like so:

function inspect(val?: unknown): string {
  if (typeof val === "string") return `"${val}"`;
  if (typeof val == "undefined") return String(val);
  if (typeof val === "function") return val.name || "anonymous function";
  if (val instanceof Either) {
    return val.fold(
      (v) => "Left(${inspect(v)})",
      (v) => `Just(${inspect(v)})`,
    );
  }
  if (val instanceof Maybe) {
    return val.fold(
      () => "Nothing",
      (v) => `Just(${inspect(v)})`,
    );
  }
  if (Array.isArray(val)) return `[${val.map(inspect).join(", ")}]`;
  if (val instanceof Object && val.constructor === Object) {
    return JSON.stringify(val);
  }
  return String(val);
}

Eyal-Shalev avatar Apr 10 '21 11:04 Eyal-Shalev

@getify Expanding on your previous answer, is it true that the Maybe methods (map,bind,ap,...) will always return a Maybe type. And the same goes for all the other categories? If yes, then it simplifies the typings a great deal.

Eyal-Shalev avatar Apr 10 '21 11:04 Eyal-Shalev

I think you can simplify the inspect function like so:

I'm not quite sure what you're suggesting. Is this a single central inspect(..) utility to be shared by all the monad kinds, instead of each one having its own like currently? Where does the val input argument come from? Are you suggesting that users of the lib would call a static Monio.inspect( myMonad )?

Or are you suggesting that each monad kind would call this central util from their own respective _inspect(..) methods?

Some things to be aware of in the current implementation of the various inspect functions:

  1. I already have a "sharing" of this behavior across monads by, for example, Either._inspect(..) casting its value using Just._inspect(..). That's not super elegant, in doing the regex match and such, but there's a predictable and limited set of inputs/outputs to be concerned with.

  2. Just(Just(Just(42)))._inspect() needs to be able to show Just(Just(Just(42))) as its output, meaning that there's a need to have "recursion" of the serializing of output. Indeed, nesting of monads is common, and can be mixed: Either:Right(Maybe:Just(42)). You might want to look at the typeof v._inspect == "function" checks, which are duck-typed handling for that recursive inspecting.

  3. These monads aren't constructed with new constructors, but rather as plain function calls, so I don't think the instanceof checks will work as a strategy.

  4. Either knows internally what kind of value it holds (Left vs Right), and so does Maybe (Just or Nothing). But this utility you've suggested "externalizes" (duplicates) that determination by relying on their respective public fold(..) methods. While that's a lawful operation, I think it's less elegant (and a tiny bit less performant).

  5. Not all of the monad kinds can hold all the possible value types... for example, IO never holds anything but a function or another monad. Centralizing the inspection rather than having it be per-monad-kind seems like it could be slightly misleading in that this detail would no longer be as obvious from looking at the inspection logic.

    I suspect you may be intending information like that to be communicated via Types. However, in general, I don't want readers of the code to have to rely on TypeScript for this kind of communication. Monio is not, and won't be, a natively TypeScript project. The TS typings are welcome as additional information that TS users can benefit from. But non-TS devs should be able to open up the JS of Monio's source code and get the information they need.

Those concerns aside, are there other reasons you think inspect should be centralized/shared rather than per-kind as it currently is? In your opinion, how does that "simplifiy" compared to the current approach?

getify avatar Apr 10 '21 12:04 getify

I'm suggesting a single inspect(...) function in the testing utility library.

Eyal-Shalev avatar Apr 10 '21 12:04 Eyal-Shalev

is it true that the Maybe methods (map,bind,ap,...) will always return a Maybe type. And the same goes for all the other categories?

Yes, and no.

map(..) on any monad kind will always return the same kind of monad. This is mechanically enforced, meaning in the implementation of the map(..) call.

ap(..) on any monad kind uses the map(..) of whatever monad you pass in. You're supposed to pass in the same monad kind, and if you do, you'll of course get back out the same kind as if you'd just called map(..). But as I mentioned above, "should" and "must" are different concepts in my opinion. There are times I consciously break the "laws" (for performance or other implementation reasons), so in theory I might pass a different monad kind to a call like ap(..). In practice, I almost certainly wouldn't do this, but it can't be ruled out entirely.

I would like the TS typings to "encourage" (aka "should") these matching, without "requiring" (aka "must") it. I don't know how easily that sort of thing can be communicated by a type system -- not my area of specialty. But in essence, this is one of the reasons Monio wasn't built as a strictly-typed FantasyLand-compliant library, because when doing JS, I feel there are times when you need to intentionally color outside the lines.

chain(..) is similar to ap(..) in this respect. You're supposed to ensure that the function you pass it will return the same kind of monad back out. In most cases that will be true. But again, as I mentioned, there are a handful of places (in and outside of Monio) that I already intentionally violate this. So the TS typings need to accommodate that reality.

Other methods that are commonly found on the monad kinds' APIs, such as concat(..) and fold(..), don't really play by those same rules.

getify avatar Apr 10 '21 13:04 getify

testing utility library

I'm sorry, I'm confused by what this is?

getify avatar Apr 10 '21 13:04 getify

  1. I looked at the performance differences between creating objects via new vs using {...} (like in monio). And found that there isn't any significant performance difference between these two methods. However, using classes gives us the benefit of checking the type using instanceof instead of duct-taping the check using a isFunction tests. So I think it's preferable.
  2. I'll elaborate on the inspect function. I suggest writing a single inspect method in /test/utils.js that will use the fold methods to do the inspection. It will mean less internal (please don't use) code inside the monads.

Eyal-Shalev avatar Apr 10 '21 13:04 Eyal-Shalev

And found that there isn't any significant performance difference between these two methods.

It's true that in modern JS engines, new Fn(..) is not significantly different in performance to Fn(..). Actually, it used to be that new Fn(..) was faster, but these differences have all smoothed out for the most part.

My concern with classes and new is not performance, per se, but ergonomics (both inside the code and the external use of the code). I am deeply opposed to JS classes, from nearly all angles. Monio is very intentionally designed with functions instead of classes, and that is not a detail I'm willing to re-consider.

I'll elaborate on my feelings about classes to illuminate that stance:

I hate littering the internals of the code with this references. I hate having methods that are dynamically context-bound (aka, normal functions) which you're having to juggle this bindings, whenever you're passing them around as callbacks (asynchrony, etc), either with lexical tricks (var self, arrow functions) or Function#bind(..) hard-binding.

As a user, I having to put new in front of every value creation when other library authors choose classes and force me to do so. I also hate having to juggle dynamic-context methods that those library authors forced me to use.

The function (factory) approach chosen by Monio elects to use closure to maintain (and protect) state internally, rather than state being exposed on public instance properties in classes. If all state is public, it can be mucked with (accidentally or intentionally), so you have less certainty. Whereas, if state is private via closure, you get protection and more certainty by default. Yes, class is just about to get private fields, but I don't want Monio to rely on that future hope and have significant extra weight in the transpiled backwards-compat builds for a long time while the older environments phase out.

All of that "cost" of choosing class for implementation brings only one tiny benefit over the function approach (i.e., all other concepts are equally expressable): the instanceof type checking you mention is slightly nicer and more ergonomic. But with a little bit of effort, the is(..) methods I've provided do a perfectly adequate job IMO of standing in for instanceof checks. They provide an official external approach to "is-a" type checking, which avoids most users of Monio from needing to do any duck-typing. They hide the uglier details the way an abstraction should.

But I guess the most compelling reason to prefer functions+closure of class+this, as a Monio implementation detail, is because in your own user code, class+this is an anti-pattern as far as FP goes, because this is essentially an implicit (side-effect) input to every method. Composition patterns are different (chained methods instead of traditional compose(..) utilities), and you end up with significant degradation in the FP purity.

So, if we shouldn't encourage users of Monio to use class in their own FP code, I don't see it as coherent to use class to implement Monio.

getify avatar Apr 10 '21 13:04 getify

@getify I'm not sure I agree with the first part of your comment (about your distaste for classes in JS). But I agree that if this library provides Monads (a functional-programming concept), it's very hypocritical to use Object oriented concepts to support it.

Eyal-Shalev avatar Apr 10 '21 14:04 Eyal-Shalev

@getify Another question: Let's say we have a Either:Right(5) and we apply .bind(n => Maybe.Just(inc(n)) on it. Should the resulting monad be Either:Right(Maybe:Just(10)) or Maybe:Just(10)?

Eyal-Shalev avatar Apr 11 '21 19:04 Eyal-Shalev

bind(..) is chain(..), and it does not apply any "wrapper" monad to the value, the way map(..) does. The callback you pass is supposed to return the appropriate kind of monad.

In your example, the result would be Maybe:Just(10). However, as I explained earlier, while this is possible to do, it's improper usage that you shouldn't do (at least not without a really good reason to violate). The wrapped result would have been if you used map(..) instead, and that would have been entirely legal.

getify avatar Apr 11 '21 20:04 getify

So proper usage of chain / bind / flatMap, is to keep inside the same category.

Another question is about the implementation of maybe.js vs either.js: It seems like Maybe extracts the values of Just, such that Maybe(Maybe(Maybe(5))) will result in Maybe:Just(5), which seems to contradict your earlier comment about recursion of values. Does this behaviour exist for the benefit of Nothing? Either doesn't extract the values on construction.

Also, I've read your is implementation again, and I like the usage of a non-exported constant to verify a monad (and its category). But I think it's overly complex. You could just place the Brand as a property of the Monad Struct, it will have the same "vulnerability" as the _is method. But the benefits are easier to understand code, and no need to check if a property is a function.

function is(val) {
  return typeof val === 'object' && !!val && val['brand'] === brand;
}

The "vulnerability" is that someone could just place a function called _is inside a non-monad struct that always returns true, which is equivalent to taking the monad['brand'] property and placing it inside a non-monad struct.

P.S> Thank you for taking the time and explaining all of that. As you see, I don't have a lot of experience with Monads.

Eyal-Shalev avatar Apr 12 '21 06:04 Eyal-Shalev

It seems like Maybe extracts the values of Just, such that Maybe(Maybe(Maybe(5))) will result in Maybe:Just(5)

This is an ergonomic affordance from Monio, specifically. The monad laws require a "unit constructor" that just wraps, so you could intentionally create a Maybe:Just(Maybe:Just(Maybe:Just(5))) if you want. But I also find it convenient to have a "constructor" that "lifts" but doesn't wrap. So... Maybe.of(..) is the unit constructor, as far as Monadic laws go (same goes for all the other monad kinds), whereas Maybe(..) is the non-monadic, Monio-specific helper constructor.

This special helper constructor is an artifact of Monio's choice to have Just be a separate identity monad, but simultaneously treated as if it's automatically a part of (or subsumable by) Maybe. So it lets you do x = Just(5) and later Maybe(x), and get a Maybe:Just(5) instead of a Maybe:Just(Maybe:Just(5)). This ergonomic affordance lets you lift Just to act as if it was initially created as a Maybe:Just, if you want.

Since Either doesn't expose Left and Right as standalone monads, it has no need for lifting a Right to be an Either:Right, and thus no need for a distinction between the Either(..) constructor and the Either.of(..) constructor. Both of them act as the unit constructor (monadically), and do the same thing as Either.Right(..): they unconditionally wrap the value (whatever it is, even a monad) in an Either:Right.

getify avatar Apr 12 '21 12:04 getify

I like the usage of a non-exported constant to verify a monad (and its category). But I think it's overly complex

I've debated this approach quite a bit in the design/evolution of Monio. I initially just had a publicly exposed property (that was writable). Then I decided to make it a read-only, non-enumerable property, to make it less likely that it would be mucked with or faked.

Then I realized those techniques were more "OO" than functional, so I switched to using the closure over a private brand for the identity checking. It's not perfect, but it's a lot more likely to "work as expected" than the public writable property.

Of course, it relies on the public method (aka property) _is. So it's shifted the question as to whether people will overwrite or fake a method as opposed to a property. I think it's far less likely that someone will fake a method -- in FP, functions are basically ALWAYS treated as constants -- but it's not intended as a bullet proof mechanism. It's trying to strongly suggest that you shouldn't go out of your way to fake out this system, or your code will fail to get the FP guarantees that's the whole point of doing this.

There's a much more fool-proof way of doing this, which is to track instances in hidden WeakMaps. I contemplated doing that, but it seems way overkill. It also has a (very slight) disadvantage: instances can't be shared across Realms (like from main page to an iframe, for example).

So bottom line, the current compromise is to favor expressing the branding check as a function since functions are sanctified in FP as being constant (even though they aren't, by default). I think that provides more protection than the simpler approaches, but doesn't take it too far.

getify avatar Apr 12 '21 13:04 getify

(btw, I've edited this thread topic to more accurately reflect what it's become: a general review of all the design/implementation decisions made in Monio thus far, for posterity sake)

getify avatar Apr 12 '21 13:04 getify

  1. Have you considered using unique symbols for the private fields (and functions)? It won't make them inaccessible, but it will make it considerably harder to access, and they won't be (normally) listed as properties.
    const is_key = Symbol('is')
    const obj = {
      [is_key]: (x) => {...}
    }
    
    const obj = {
      [Symbol.for('is')]: (x) => {...}
    }
    
  2. Below is what I thought about when suggesting using a brand property, instead of the _is method:
    // internal/utils.js
    export const brandKey= Symbol('brand')
    // either.js
    import {brandKey} from "./internal/utils.js";
    const brand = Symbol('Either')
    function LeftOrRight(val) {
      const publicAPI = Object.freeze({
        ...
        [brandKey]: brand
      });
      // ...
    }
    function is(val) {
      return typeof val === 'object' && !!val && val[brandKey] === brand;
    }
    
  3. Concerning the Maybe code. I'm not sure I understand which function should be used by users of monio: Maybe, Just, Maybe.Just, Just.of, Maybe.of, Maybe.Just.of ?
  4. According to what you wrote above (if I understood correctly), the reason for separating Just & Nothing from Maybe, was to make the library more approachable by providing an identity constructor. But wouldn't that be also available by just exposing the Maybe.Just function as Just (without separating the concepts)?

Eyal-Shalev avatar Apr 12 '21 20:04 Eyal-Shalev

Have you considered using unique symbols for the private fields (and functions)?

I briefly considered it, yes, at the same time (as mentioned) I was experimenting with the read-only/non-enumerable properties. It seemed like overkill, and more along the lines of how OO classes identify themselves, as opposed to how plain ol' objects with closure'd methods work. I decided, and still think, the _is(..) method closed over a privately held brand value is more FP-esque than those other approaches.

Below is what I thought... Object.freeze

Understood. Again, I went down a similar route early on, but ultimately decided instead of trying to protect it through various means, I would just make it clear that the value was off-limits by hiding it behind a function closure. Anyone doing FP should recognize that and respect it. If they don't, they deserve whatever code breakage they get themselves into. I don't want code weight or performance costs to keep their hands off it. Closure gives me what I want pretty much for free.

not sure I understand which function should be used by users of monio: Maybe, Just, Maybe.Just, Just.of, Maybe.of, Maybe.Just.of ?

Any of them, depending on their needs!

If they want a monadic unit constructor, their best option is Maybe.of(v). The next best option would be Maybe(Just.of(v)). If they want to lift an already created Just into a Maybe, then they have to use Maybe(..) (which, again, isn't monadic, it's just a convenience helper from Monio).

For Just, Just(42) and Just.of(42) are identical, so you can pick whichever you're more comfortable. For Either, Either(42) is the same as Either.Right(42).

Also note that Maybe has another helper which is not a unit-constructor, but is quite common and useful (I use it all the time): Maybe.from(..). That helper actually decides between Just and Nothing based on doing the empty-check on what you pass in. That conditional is not allowed in the unit constructor, interestingly enough, because the monad laws have to hold for all values equally. But the whole point of using Maybe is to conditionally create Just or Nothing, so that's what Maybe.from(..) does.

Similarly, Either has Either.fromFoldable(..), which (using fold(..) on the foldable/monad in question) determines if you mean to create Either:Left or Either:Right. I typically use this as a "natural transformation" from Maybe To Either, where Maybe:Nothing becomes Either:Left and Maybe:Just becomes Either:Right.

the reason for separating Just & Nothing from Maybe ... wouldn't that be also available by just exposing the Maybe.Just function as Just (without separating the concepts)?

Not quite. Just(..) produces a value that acts only as the identity monad. By contrast, a Maybe:Just instance is actually a Maybe, so notably it has fold(..) on it and all its methods are short-circuited on Maybe:Nothing instances.

And in practice, you'd probably never actually manually make Nothing instances, but it IS possible you might use it as an "empty" value in a reduction/fold or something like that.

Ultimately, there was no reason in my mind not to have Just and Nothing be separate, since at a minimum they're very convenient for illustrating monad principles on, without any other fanfare. And in some specific cases, they may actually prove useful. Either way, in those cases you'd want the most stripped down and predictable thing, not some more powerful Maybe value that comes with other behavioral characteristics.

getify avatar Apr 12 '21 22:04 getify

On an unrelated note, I see that there is no handling for invalid input in case of the #ap and #concat methods. Does that mean you are okay with throwing errors in these cases? I ask because this kind of thing needs to be tested for in valid typescript.

For example:


  function ap(m: Monad<GetParam<R>>): Monad<GetReturn<R>> | Either<L, R> {
    if (isLeft(lr)) return publicAPI;
    if (!isFunc<GetParam<R>, GetReturn<R>>(lr.val)) {
      throw new TypeError("Not a function");
    }
    return m.map(lr.val);
  }

Note 1: Monad and Either are types in the above example, not classes. Note 2: lr (in this implementation) is a struct that contains the value and a flag indicating left or right. It was needed to distinguish between an either holding a left value or a right value - the either type has a type parameter for both.

Eyal-Shalev avatar Apr 13 '21 20:04 Eyal-Shalev

I see that there is no handling for invalid input

In general, there's very little nagging if you pass the "wrong" inputs. This is on purpose, in part to keep file size down and performance lean, and in part to offer flexibility in case you know what you're doing and need to color outside the lines.

The types system doesn't necessarily have to be as flexible as that, but as I've mentioned earlier, it's a known scenario that a function like chain(..) is going to receive a function that has a different monad return kind, even though you're not supposed to do that.

While we're discussing these gray areas, does TS offer some idea like defining types in multiple levels of strictness? Like, for example, could a project like Monio have a set of types that are very strict, and another set of types that are a bit more flexible, and then users can choose to opt into one set of types or another? If that kind of thing is possible, somehow, I think it would be best for Monio. You just won't get the most out of Monio if your proclivity is to absolutely type single bit and strictly and narrowly adhere to those, because that's not the philosophy I'm using to design this library.

getify avatar Apr 13 '21 20:04 getify