Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
Eyal-Shalev opened this issue Jan 20, 2021 · 47 comments
Open
Labels
question Further information is requested

Comments

@Eyal-Shalev
Copy link

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

@getify
Copy link
Owner

getify commented Jan 21, 2021

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?

@Eyal-Shalev
Copy link
Author

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
Copy link
Author

Eyal-Shalev commented Jan 22, 2021

@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
Copy link
Author

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

@getify
Copy link
Owner

getify commented Jan 22, 2021

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.

@Eyal-Shalev
Copy link
Author

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"
	);
});

@getify
Copy link
Owner

getify commented Jan 22, 2021

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

@DrBoolean
Copy link
Contributor

👋 Nailed it

@Eyal-Shalev
Copy link
Author

@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?

@getify
Copy link
Owner

getify commented Apr 9, 2021

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
Copy link
Owner

getify commented Apr 9, 2021

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.

@Eyal-Shalev
Copy link
Author

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
Copy link
Author

Eyal-Shalev commented Apr 10, 2021

@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.

@getify
Copy link
Owner

getify commented Apr 10, 2021

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?

@Eyal-Shalev
Copy link
Author

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

@getify
Copy link
Owner

getify commented Apr 10, 2021

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
Copy link
Owner

getify commented Apr 10, 2021

testing utility library

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

@Eyal-Shalev
Copy link
Author

  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.

@getify
Copy link
Owner

getify commented Apr 10, 2021

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.

@Eyal-Shalev
Copy link
Author

@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
Copy link
Author

Eyal-Shalev commented Apr 11, 2021

@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)?

@getify
Copy link
Owner

getify commented Apr 11, 2021

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.

@Eyal-Shalev
Copy link
Author

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.

@getify
Copy link
Owner

getify commented Apr 12, 2021

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
Copy link
Owner

getify commented Apr 12, 2021

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 getify changed the title Typescript Support Reviewing Monio's design/behavior (formerly: Typescript Support) Apr 12, 2021
@getify
Copy link
Owner

getify commented Apr 12, 2021

(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)

@Eyal-Shalev
Copy link
Author

Eyal-Shalev commented Apr 12, 2021

  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)?

@getify
Copy link
Owner

getify commented Apr 12, 2021

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.

@Eyal-Shalev
Copy link
Author

Eyal-Shalev commented Apr 13, 2021

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.

@getify
Copy link
Owner

getify commented Apr 13, 2021

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.

@Eyal-Shalev
Copy link
Author

Eyal-Shalev commented Apr 13, 2021

  1. TypeScript compiles to JavaScript, so a user that doesn't want to be lawful can always just use that.
  2. Users can always define their values as any which overrides the type system, and as such is usually not recommended - if you don't want to use types, use JavaScript.
    Just(5).bind(identity as any)
  3. Users can always lie to TypeScript about the type of their parameter. For example:
    Just(5).bind(identity as () => Monad<number>)
    In the above example, we pass the identity function, but lie to TypeScript by telling it that the return value is going to be a Moand<number>.
    I lie to TypeScript in my tests, to check that it throws errors.
  4. We can have 2 sets of types, but as there are the 3 options above, I'm not sure if a loose version is needed.

@Eyal-Shalev
Copy link
Author

Eyal-Shalev commented Apr 23, 2021

@getify I'm trying to figure out how to translate the IO.do method and I have a query.
Let's say we have the following block (correct me if the usage example is incorrect):

const main = IO.do(({window: win}) => {
  yield IO(({window: win}) => window.console.log('hello'))
  // ...
  yield IO(({window: win}) => window.console.log('world'))
});

main.run({window})

Is it true that the input for each yielded IO will always be the value inserted into main.run I.E. {window}?

If not, then when and how does it change?

@getify
Copy link
Owner

getify commented Apr 23, 2021

Inside a "do routine", an expression like yield someIO is kinda the same as someIO.run(env), where env is the Reader env (if any) passed into the outer routine's run(..) call, and is also the first argument passed to the generator.

I say "kinda" because if the expression isn't an IO, but is instead a promise, then the promise is waited on to unwrap its value. If it's a Maybe or Either, it's also unwrapped (and short-circuits out if need be). And if it's an IO, and the run(..) call returns a promise, that promise is waited on and unwrapped. So yield here is a somewhat involved bit of sugar. You can conceptually think of it kinda like a sophisticated chain(..) call.

So by default, a Reader env propagates all the way down (sorta recursively) through all chained IO/Readers... unless you forcibly override it (which I often do, to narrow/alter the applicable env further down the call chain).

IO.do(function *main(env1){
   yield IO(() => IO.do(another).run({
      x: env1.x + 1,
      y: 3
   }));
})
.run({ x: 1 });

function *another(env2) {
   console.log(env2.x,env2.y);
   // 2 3
} 

@Eyal-Shalev
Copy link
Author

Yes, that's what I thought.

Though, it seems redundant to pass the "real world" to both the generator function and to the IO Yields. Isn't it better to only pass it to the IO Yields, as a way of saying, if you want access to the "real world", then you have to use an IO operation?

By passing the "real world" to the generator function, you open the door for developers to use it outside of an IO operation:

IO.do(function *main(env1){
   yield  IO.do(another).run({
      x: env1.x + 1,
      y: 3
   });
})
.run({ x: 1 });

function *another(env2) {
   console.log(env2.x,env2.y);
   // 2 3
} 

It seems like I'm using the "real world" in an IO operation, but I'm not. However, if the "real world" was passed only to IO operation, the above example will obviously not work, and by accessing global variables, it becomes obvious that they don't use monadic principles.

IO.do(function *main(){
   yield IO((env1) => IO.do(another).run({
      x: env1.x + 1,
      y: 3
   }));
})
.run({ x: 1 });

function *another(env2) {
   console.log(env2.x,env2.y);
   // 2 3
} 

@getify
Copy link
Owner

getify commented Apr 24, 2021

There's a variety of reasons the Reader env is passed into both the IO function and the do-routine.


First, understand... a do-routine itself is a significant readbility/convenience affordance, which in essence communicates "everything here is a (potential) side effect". I think typically you assume everything in a do-routine is a side-effect unless otherwise noted.

In that context, there's not particularly any strong reason to have to make things (like accessing and making side effects) more difficult inside the do-routine, since that defeats the purpose of using it in the first place.

For example, imagine I was going to do this:

IO.do(function *main(){
   var btn = yield getElement("#my-btn");
   btn.disabled = false;  // this line is a side effect!
});

The first line uses a yield and calls an IO-returning function, so it's more clear it's a side effect. The second line is also a side effect, but it's admittedly a bit less clear. Can you instead do this?

IO.do(function *main(){
   var btn = yield getElement("#my-btn");
   yield IO(() => btn.disabled = false);
});

Sure, of course you can. And if you find yourself doing that a lot, you can make a helper...

IO.do(function *main(){
   var btn = yield getElement("#my-btn");
   yield disableElement(btn);
});

But for the one-off situations, do we need to force ourselves into a discipline of having every single side-effect line in a do-routine being wrapped in an IO? This is ultimately a judgement call. The whole reason you dropped out of an IO chain into a do-routine was because you wanted some readability affordances, like being able to assign temporary local variables to carry across calls. You could make the argument that btn.disabled = false is a perfectly fine operation to perform in a do-routine all by itself, and gains little to nothing by being wrapped in an IO.

In my apps, I typically will only wrap a side-effecting line of a do-routine in an inline IO if it's an external function call that performs a side-effect, and that's not obvious. But in the case of assigning a property, I would either use a helper if I had one defined, or I would just do the operation directly in the do-routine.

My point is: not all side effects in a do-routine have to be wrapped in an IO... so forcing a wrapping in an IO just to access the Reader env is more hostile, and I will argue it does so for no good benefit.


Now, to the direct question of accessing the Reader env... do we need to make it difficult or can we reasonably offer an affordance for accessing it?

Let's say I need explicit access to the env object inside the do-routine and it wasn't passed to the do-routine. I could do this:

const getEnv = () => IO(identity);

IO.do(function *main(){  // no env passed in here
   var env = yield getEnv();
   // now do whatever I want with env
});

Since I can do that, why not just pass env as an argument? It's a reasonable usability affordance to prevent you from having to define a getEnv(..) helper and then call it at the top of most of your do-routines.

You always have the option of NOT accessing the argument passed to the do-routine, and instead explicitly calling something like getEnv(..) to get it. In fact, I provide such a helper in the IO-Helpers module, called getReader(..). But I don't think I should force you to do that if your style of coding is going to access it frequently in all or most of your do-routines. Indeed, in my apps, I access the Reader env frequently, in essentially every do-routine in my app (hundreds of them).


Why is accessing the env directly useful? Here's a few examples (not an exhaustive list):

function *main(env){
   var { window, myBtn } = env;  // this is nice!
   // ..
}

function *main({ window, myBtn }){  // this is even nicer!
  // ..
}

function *main(){
   var { window, myBtn } = yield getEnv();  // this is workable, but isn't as nice
   // ..
}

The second one there is my favorite, because it declaratively exposes at the definition of the do-routine what its (Reader env) dependencies are.

Besides destructuring the env to access its individual properties, it's helpful to have the explicit env object in cases where I want to create a "bound" callback (a normal function passed to some non-monadic API that only wants a function) that is still invoked as an IO in the current Reader context:

function *main(env){
   var { document: doc } = env;
   var readyCB = doIOBind(onReady,env);  // doIOBind(..) is in IO-Helpers, and notice no `yield` here
   doc.addEventListener("DOMContentLoaded",readyCB);
}

I also often want to amend (add to, or reduce) the Reader env for a certain sub-section of do-routines (modules) in my app:

function *main(env){
   var btn = yield getElement("#my-btn");
   var newEnv = {
      ...env,
      btn
   };

   yield applyIO( IO.do(another), newEnv );
}

function *another({ document, btn }) {
   // ..
}

In my apps, I call the Reader env viewContext, and I treat it like a parameterized "global" that I can expand or contract to include only the references to shared values (like DOM element references, etc) that are used across the do-routines. I try to limit the Reader env to as narrow as each tree of do-routine calls (aka, an app module) needs. IOW, don't pass in btn to that module if it's not going to need it for any of its do-routines.

@Eyal-Shalev
Copy link
Author

The thing that troubles me concerns the purpose of this library.
I understand that users of Monio can pull the environment using const env = yield IO(identity) and then perform side effects outside of an IO monad, but it seems to me, like breaking the monadic principles.
In other words, if they don't want to program using Monads, they can just do that.
I think that by providing the environment as a parameter to the generator function, you are inviting them to break monadic principles.


Concerning the disable button example. I would argue that all 3 break monadic principles, and that a monadic version should be something like this (where the side-effectable object is not extracted from the IO operation):

IO.do(function* (){
  yield getElement("#my-btn").map((btn) => { btn.disable = true });
})

Obviously people are going to misuse this library, but I think that the API, and usage examples should direct people to the monadic way.
Speaking of which, I was having a lot of trouble finding good usage examples for monio. I suggest adding an example.test.js file with various usage examples.


Similar to the above example, I would argue that in the yield applyIO( IO.do(another), newEnv ) a monadic way to handle it should use getElement(...).chain() as such:

IO.do(function* () {
  // Adding only 1 element using a `.chain`.
  yield getElement("#my-button")
    .chain((btn) => IO((env) => another.run({...env, btn})))

  // Adding 2 elements using a `.map` and a `.chain`.
  yield getElement("#my-button")
    .chain((btn) => getElement("my-div").map((div) => {btn,div}))
    .chain((extra) => IO((env) => another.run({...env, ...extra})))

  // Like above but creating the new IO chain first, and then calling `.map` on `another.run`.
  yield getElement("#my-button")
    .chain((btn) => getElement("my-div").map((div) => {btn,div}))
    .chain((extra) => IO((env) => ({...env, ...extra})).map(another.run) )

  // Adding 2 elements using `.concat` (by turning them into arrays first).
  yield getElement("#my-button").map(Array.of)
    .concat((btn) => getElement("my-div").map(Array.of))
    .chain(([btn,div]) => IO((env) => another.run({...env, btn, div})))
})
// No need to keep the generator function outside of an `IO.do`.
const another = IO.do(function* () {
  yield IO((newEnv) => {
    const {btn} = newEnv;
    // Do something with btn & newEnv
  })
});

The onReady example can be easily monad-ified using a new Promise(), but I think I understood your point so I altered the scenario for listening to button presses.

const main = IO.do(function* () {
  yield getElement("#my-button")
    .chain(btn => (
      IO(env => btn.addEventListener("click", bind(handleClick, {...env, btn}))
    );
});

const handleClick = IO.do(function* () {
  yield IO(([event, {btn}]) => {
    // Do something with event (and/or btn).
  });
})

const bind = (io, env) => (...args) => io.run([...extra, env])

I really like the monadic idea, and I'm learning a lot (about monads) through this experience. But these kind of inconsistencies keep me awake at night.
As I said above, I know that users of such libraries will misuse it and perform side effects outside of an IO operation, but I think that the library that promotes Monads, shouldn't.

@getify
Copy link
Owner

getify commented Apr 25, 2021

Think of IO.do(..) as a specialized IO constructor. IOW:

var x = IO.do(main);

function *main() { /*.. */ }

This code doesn't do anything... it merely sets up an IO monad instance x. Therefore, everything inside main(..) is all perfectly monadic because it's all inside an IO monad.

IOs are only executed where you see a run(..) call, usually after chaining together dozens or hundreds of different IO instances into a big tree of operations, none of which have actually happened yet until that single run(..) kicks everything off. Until that moment, everything is perfectly pure and perfectly monadic. And at that moment when IO runs, it doesn't matter whether it's pure anymore (it isn't!), because the whole point was to do impure stuff, at that exact controlled moment.

Guess what: in a do-routine (or any IO constructor), you can even directly access and mutate globals! Why is this OK? Because that's the point of IO monads, to offer a lazy trigger to control when side effects happen, not keep them from ever happening.

Inside the do constructor, you use generator syntax instead of fluent chain'ing API syntax, because the point of the do syntax is to drop out of the declarative chain style in favor of imperative style (inside the generator).

Imperative do style doesn't mean non-monadic or impure, necessarily. It just means "does not need to be expressed as a series of chain(..) calls".

You can do as many nested chain calls as you like doing in there. But the point of the do syntax is to escape from the tyranny/limitations of "pure" chain calls and use imperative constructs like variable declarations/assignments, if statements, loops, or whatever else you want to do that would be awkward or hard to read in the chain API style.

If you're fine doing something with "pure" chain calls, do so, and don't put your code in a do-routine.


More to the bigger design question... the entire reason for Monio to exist is to provide a uniquely pragmatic flavor of the imperative do-syntax as a more welcoming "bridge" to the non-FP-centric, non-monad-zealot typical JS dev. I believe I know their mindset because I have been one for decades (only a recent partial convert to FP thinking) and I have taught literally thousands of them over the last decade.

I do not believe being able to access the Reader env as an explicit object is a violation of any FP or monad principles.

Can such things be abused? Of course, all JS code can be, because the engine doesn't enforce rules. And I don't think it should! That's why I choose JS (and, frankly, don't choose TS myself).

My theory is: Monio can bridge the gap from non-FP, non-monadic coding to FP/monadic coding, in a way that other monad-in-JS libs have failed to do, precisely because it offers these inviting/friendly affordances.

The Reader env object being directly accessible, if needed, is a critical usability affordance (among several others, like transparent Promise transformation) that makes the coding style in Monio inviting enough that someone like me is willing to convert from 25 years worth of experience in imperative JS coding to this more FP/monadic style. I find it just the right compromise between the two extremes, just enough to pull me in and get me hooked. I hope it attracts others similarly.

@Eyal-Shalev
Copy link
Author

Now that my TS implementation has IO support, I rewrote the build script to use IO - to prove to myself it actually works. It does, though I discovered a (now fixed) bug that I'm not sure if you addressed.
Let's say we have a generator function that causes an error to be thrown during an IO operation. IO.do catches that error and tries to throw it back to the generator using it.throw(v). But what if the generator function doesn't have a try-catch block surrounding that yield call?
In my case, it first caused an unhandled rejected promise error, and then an endless loop when I caught it in the IO.do code.
To address this, I'm comparing the caught error with the last value passed (or thrown) to the generator function, if it is equal, then I throw again (knowing that it will cause an unhandled rejected promise error).

try {
//...
} catch (e) {
  if (v === e) {
    // If we get the same error we threw, then the generator isn't catching it.
    // This will cause the an "Uncaught (in promise)" runtime error though.
    throw (e instanceof Error) ? e : new Error(inspect(e));
  }
  return next(e, "error");
}

@Eyal-Shalev
Copy link
Author

Concerning your response (I meant to reply in the last comment but got distracted).
After experiencing with the IO.do myself a bit, I get your point about it not mattering that much.
Though, my suggestion is to update the examples such that if you use a yield IO(...). then actually use the env inside that block (not the one in the generator function parameter).

If I look at the README example, I would update to something like this:

const world = Just("World");
const greeting = Maybe(world);

// Helpers functions can return an IO that accepts the global environment to produce side effects.
const log = (...data) => IO(({console: _console}) => _console.log(...data));

// Our main IO.
const main = IO.do(function *main({greet}){
    const msg = greeting.map(m => `${greet} ${ m }!!`);

    // Uncomment this line to swap in an empty maybe
    // msg = Maybe.from(null);

    yield msg.fold(IO.of,log);
});

// Add custom properties to the globalThis (window / self / global / ...) when running the IO.
main.run({...globalThis, greet: "Hello"});

// => Hello World!!

@getify
Copy link
Owner

getify commented Apr 25, 2021

Side note: IO.do(..) also accepts an iterable, for exactly the case where you want to invoke a generator and pass it extra arguments (not polluting in the Reader env)... so the more canonical way to do what you suggested would be:

function *main(env, greet) {
    const msg = greeting.map(m => `${greet} ${ m }!!`);

    yield msg.fold(IO.of,log);
}

// Helpers functions can return an IO that accepts the global environment to produce side effects.
const log = (...data) => IO(({console: _console}) => _console.log(...data));

// ************************

const world = Just("World");
const greeting = Maybe(world);

IO.do( main(globalThis,"Hello") )
.run(globalThis);
// => Hello World!!


// or, using `doIO(..)` helper:

doIO( main, "Hello" )
.run(globalThis);
// => Hello World!!

@Eyal-Shalev
Copy link
Author

Other side note: It is better to document IO.do as only accepting generators, not iterators. There isn't any reference to a .throw method on Iterators, and according to the TypeScript typings, it is only an optional method.
From the typings, function* () {} is a function that returns a Generator object.

Concerning your side note, I wanted to show how we can add custom properties to the outerEnv parameter being passed to all the IO operations, so your suggestion won't accomplish that.
Though, my example was a bit contrived. Maybe a better example would be importing a 3rd party library that does IO, and adding that into the outerEnv parameter like so:

import axios from 'https://cdn.skypack.dev/axios';

const world = Just("Hello World");
const greeting = Maybe(world);

// Helpers functions can return an IO that accepts the global environment to produce side effects.
const log = (...data) => IO(({console: _console}) => _console.log(...data));

// Our main IO.
const main = IO.do(function *main({axios}){
    const msg = greeting.map(m => `${ m }!!`);

    // Uncomment this line to swap in an empty maybe
    // msg = Maybe.from(null);

    yield msg.fold(IO.of,log);

    // Do something with axios
});

// Add custom properties to the globalThis (window / self / global / ...) when running the IO.
main.run({...globalThis, axios});

@getify
Copy link
Owner

getify commented Apr 26, 2021

It is better to document IO.do as only accepting generators, not iterators. There isn't any reference to a .throw method on Iterators, and according to the TypeScript typings, it is only an optional method.
From the typings, function* () {} is a function that returns a Generator object.

Whether we call it a "generator object" or an "iterator", it's definitely only an iterator that comes from a generator. There should be a sub-type of Iterator that includes what generators add. Whatever that type is, that's what IO.do(..) also accepts.

IO.do(..) also accepts the asyncIterator from an async generator, too... I guess called an "async generator object".

@Eyal-Shalev
Copy link
Author

Eyal-Shalev commented Apr 26, 2021

Yes.
The more important point, is that .throw may not exist on an Iterator object.
Which is why I suggested using Generator to document IO.do.
And maybe add an explanation that if an Iterator is passed, they better have a .throw method enabled for it.

Same for the async versions - obviously

@getify
Copy link
Owner

getify commented Apr 27, 2021

You asked for more "real" examples of Monio code... I have not open-sourced the code for this app I've built yet, but I figured I could share one of the functions here:

doIOBackground(setupMain).run(globalThis);


// ****************************************

function *setupMain(window = window) {
	var { document, navigator, } = window;

	// did we have a defined starting page?
	var startPage = yield IO(() => (
		(document.location.hash || "")
		.toLowerCase()
		.replace(/^#/,"")
		.replace(/[^\w\d]+/g,"")
	));
	yield iif(!!startPage, $=>[
		IO(() => document.location.hash = ""),
	]);

	// create event emitter to be shared "globally"
	var events = new EventEmitter2({
		wildcard: true,
		maxListeners: 10,
		delimiter: ":",
		ignoreErrors: true,
	});

	// setup DOM event handling
	var DOMEventManager = yield applyIO(
		manageDOMEvents(events),
		{
			window,
			document,
		}
	);

	// setup the action-events stream
	var actionEvents = yield IOEventStream(events,"action");
	// normally, IO Event Streams are lazily subscribed,
	// but here we want to force it to start eagerly
	yield IO(() => actionEvents.start());

	// get settings (from local storage)
	var settings = yield applyIO(
		doIO(retrieveSettings),
		{ window, }
	);

	// initialize app management (service worker,
	// PWA install, etc)
	//
	// NOTE: even though we yield here (to indicate
	// side-effects), we aren't waiting on it; it's
	// a persistent background process (IO do-routine)
	yield applyIO(
		doIOBackground(initAppManagement),
		{
			window,
			document,
			navigator,
			events,
			settings,
			DOMEventManager,
		}
	);

	// determine how to listen for changes in page visibility
	var [
		pageVisibilityProperty,
		visibilityChangeEvent,
	] = listHead(
		yield listFilterInIO(
			([propName,evtName]) => (
				getPropIO(propName,document).map(v => v != null)
			),
			[
				[ "visibilityState", "visibilitychange" ],
				[ "webkitVisibilityState", "webkitvisibilitychange" ],
				[ "mozVisibilityState", "visibilitychange" ],
			]
		)
	) || [];

	// wait for the DOM to be ready
	yield whenDOMReady();

	// sync the viewport size in CSS
	yield applyIO(doIO(computeViewportDimensions),{ document, });

	// get main DOM elements
	var cardsEl = yield getElement("cards");
	var behindCardEl = yield findElement(cardsEl,".behind");
	var behindFrontFaceEl = yield findElement(behindCardEl,".front-face");
	var behindBackFaceEl = yield findElement(behindCardEl,".back-face");
	var currentCardEl = yield findElement(cardsEl,".current");
	var currentFrontFaceEl = yield findElement(currentCardEl,".front-face");
	var currentBackFaceEl = yield findElement(currentCardEl,".back-face");
	var currentCardCoverEl = yield findElement(currentCardEl,".cover");
	var nextCardEl = yield findElement(cardsEl,".next");
	var nextFrontFaceEl = yield findElement(nextCardEl,".front-face");
	var nextBackFaceEl = yield findElement(nextCardEl,".back-face");

	// get template(s)
	var tmplWelcomeCard = innerText(yield getElement("tmpl-welcome-card"));

	// define a view-context to run in
	var viewContext = {
		window,
		navigator,
		document,
		pageVisibilityProperty,
		visibilityChangeEvent,

		cardsEl,
		behindCardEl,
		behindFrontFaceEl,
		behindBackFaceEl,

		currentCardEl,
		currentFrontFaceEl,
		currentBackFaceEl,
		currentCardCoverEl,

		nextCardEl,
		nextFrontFaceEl,
		nextBackFaceEl,

		tmplWelcomeCard,

		state: {
			startPage,
			currentCardType: "",
			currentCardFace: "front",
			UIEventsAllowed: true,
			optionsMenuOpen: false,

			// manage hints
			tapHintTimer: null,
			tapHintShownTimer: null,
			swipeHintTimer: null,
			swipeHintShownTimer: null,
			hintsStatus: {
				welcomeSwipe: "hidden",
				practiceSwipe: "hidden",
				practiceTap: "hidden",
			},
		},

		settings,
		events,
		actionEvents,
		DOMEventManager,
		DOMEventRouter: null,
		getMainState: null,
	};

	// define and store view-context-bound DOM event router
	var DOMEventRouter = viewContext.DOMEventRouter =
		doIOBind(routeDOMEvents,viewContext);

	// define state-sharing accessor
	viewContext.getMainState = doIOBind(
		shareStates([
			"UIEventsAllowed",
			"optionsMenuOpen",
		]),
		viewContext
	);

	// initialize options-menu controller
	//
	// NOTE: even though we yield here (to indicate
	// side-effects), we aren't waiting on it; it's
	// a persistent background process (IO do-routine)
	yield applyIO(doIOBackground(setupOptionsMenu),{
		document,
		currentCardEl,
		events,
		DOMEventManager,
	});

	// run the rest in this new view-context env
	return applyIO(doIO(runMain),viewContext);
}

This code uses Monio as well as a companion UI framework I built on top of Monio that's called Domio.

Notice that here I set up the viewContext object, with DOM element references and a state store, and then I invoke the rest of the app on the last line via the runMain(..) do-routine being invoked in that context.

@Eyal-Shalev
Copy link
Author

That's great.
I did find Domio and have looked there for code examples, though I couldn't find something as straightforward as you pasted above.

@getify getify added the question Further information is requested label Feb 12, 2022
@kee-oth
Copy link

kee-oth commented Sep 7, 2022

@Eyal-Shalev Hi! I'm just resurrecting this thread to see if you were still interested in creating the d.ts files for this project. Thanks!

@getify
Copy link
Owner

getify commented Sep 7, 2022

I wonder if we could use something like this tool called "derive-type", to automatically generate the types, perhaps from running the instrumentation while executing the test suite.

I don't think it would be perfect, but it'd probably get 99% of the types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

4 participants