Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Combining Maybe and IO monads for DOM read/write

I'm trying to cook up a simple example using IO and Maybe monads. The program reads a node from the DOM and writes some innerHTML to it.

What I'm hung up on is the combination of IO and Maybe, e.g. IO (Maybe NodeList).

How do I short circuit or throw an error with this setup?

I could use getOrElse to extract a value or set a default value, but setting the default value to just an empty array doesn't help anything.

import R from 'ramda';
import { IO, Maybe } from 'ramda-fantasy';
const Just    = Maybe.Just;
const Nothing = Maybe.Nothing;

// $ :: String -> Maybe NodeList
const $ = (selector) => {
  const res = document.querySelectorAll(selector);
  return res.length ? Just(res) : Nothing();
}

// getOrElse :: Monad m => m a -> a -> m a
var getOrElse = R.curry(function(val, m) {
    return m.getOrElse(val);
});


// read :: String -> IO (Maybe NodeList)
const read = selector => 
  IO(() => $(selector));

// write :: String -> DOMNode -> IO
const write = text => 
                  (domNode) => 
                    IO(() => domNode.innerHTML = text);

const prog = read('#app')
                  // What goes here? How do I short circuit or error?
                  .map(R.head)
                  .chain(write('Hello world'));

prog.runIO();

https://www.webpackbin.com/bins/-Kh2ghQd99-ljiPys8Bd

like image 893
Will M Avatar asked Apr 06 '17 15:04

Will M


2 Answers

You could try writing an EitherIO monad transformer. Monad transformers allow you to combine the effects of two monads into a single monad. They can be written in a generic way such that we can create dynamic combinations of monads as needed, but here I'm just going to demonstrate a static coupling of Either and IO.

First we need a way to go from IO (Either e a) to EitherIO e a and a way to go from EitherIO e a to IO (Either e a)

EitherIO :: IO (Either e a) -> EitherIO e a
runEitherIO :: EitherIO e a -> IO (Either e a)

And we'll need a couple helper functions for taking other flat types to our nested monad

EitherIO.liftEither :: Either e a -> EitherIO e a
EitherIO.liftIO :: IO a -> EitherIO e a

To conform with fantasy land, our new EitherIO monad has a chain method and of function and obeys the monad laws. For your convenience, I also implemented the functor interface with the map method.

EitherIO.js

import { IO, Either } from 'ramda-fantasy'
const { Left, Right, either } = Either

// type EitherIO e a = IO (Either e a)
export const EitherIO = runEitherIO => ({
  // runEitherIO :: IO (Either e a)
  runEitherIO, 
  // map :: EitherIO e a => (a -> b) -> EitherIO e b
  map: f =>
    EitherIO(runEitherIO.map(m => m.map(f))),
  // chain :: EitherIO e a => (a -> EitherIO e b) -> EitherIO e b
  chain: f =>
    EitherIO(runEitherIO.chain(
      either (x => IO.of(Left(x)), (x => f(x).runEitherIO))))
})

// of :: a -> EitherIO e a
EitherIO.of = x => EitherIO(IO.of(Right.of(x)))

// liftEither :: Either e a -> EitherIO e a
export const liftEither = m => EitherIO(IO.of(m))

// liftIO :: IO a -> EitherIO e a
export const liftIO = m => EitherIO(m.map(Right))

// runEitherIO :: EitherIO e a -> IO (Either e a)
export const runEitherIO = m => m.runEitherIO

Adapting your program to use EitherIO

What's nice about this is your read and write functions are fine as they are - nothing in your program needs to change except for how we structure the calls in prog

import { compose } from 'ramda'
import { IO, Either } from 'ramda-fantasy'
const { Left, Right, either } = Either
import { EitherIO, liftEither, liftIO } from './EitherIO'

// ...

// prog :: IO (Either Error String)
const prog =
  EitherIO(read('#app'))
    .chain(compose(liftIO, write('Hello world')))
    .runEitherIO

either (throwError, console.log) (prog.runIO())

Additional explanation

// prog :: IO (Either Error String)
const prog =
  // read already returns IO (Either String DomNode)
  // so we can plug it directly into EitherIO to work with our new type
  EitherIO(read('#app'))
    // write only returns IO (), so we have to use liftIO to return the correct EitherIO type that .chain is expecting
    .chain(compose(liftIO, write('Hello world')))
    // we don't care that EitherIO was used to do the hard work
    // unwrap the EitherIO and just return (IO Either)
    .runEitherIO

// this actually runs the program and clearly shows the fork
// if prog.runIO() causes an error, it will throw
// otherwise it will output any IO to the console
either (throwError, console.log) (prog.runIO())

Checking for errors

Go ahead and change '#app' to some non-matching selector (eg) '#foo'. Re-run the program and you'll see the appropriate error barfed into the console

Error: Could not find DOMNode

Runnable demo

You made it this far. Here's a runnable demo as your reward: https://www.webpackbin.com/bins/-Kh5NqerKrROGRiRkkoA



Generic transform using EitherT

A monad transformer takes a monad as an argument and creates a new monad. In this case, EitherT will take some monad M and create a monad that effectively behaves has M (Either e a).

So now we have some way to create new monads

// EitherIO :: IO (Either e a) -> EitherIO e a
const EitherIO = EitherT (IO)

And again we have functions to lifting the flat types into our nested type

EitherIO.liftEither :: Either e a -> EitherIO e a
EitherIO.liftIO :: IO a -> EitherIO e a

Lastly a custom run function that makes it easier to handle our nested IO (Either e a) type - notice, one layer of abstraction (IO) is removed so we only have to think about the Either

runEitherIO :: EitherIO e a -> Either e a

EitherT

is the bread and butter - the primary difference you see here is that EitherT accepts a monad M as an input and creates/returns a new Monad type

// EitherT.js
import { Either } from 'ramda-fantasy'
const { Left, Right, either } = Either

export const EitherT = M => {
   const Monad = runEitherT => ({
     runEitherT,
     chain: f =>
       Monad(runEitherT.chain(either (x => M.of(Left(x)),
                                      x => f(x).runEitherT)))
   })
   Monad.of = x => Monad(M.of(Right(x)))
   return Monad
}

export const runEitherT = m => m.runEitherT

EitherIO

can now be implemented in terms of EitherT – a dramatically simplified implementation

import { IO, Either } from 'ramda-fantasy'
import { EitherT, runEitherT } from './EitherT'

export const EitherIO = EitherT (IO)

// liftEither :: Either e a -> EitherIO e a
export const liftEither = m => EitherIO(IO.of(m))

// liftIO :: IO a -> EitherIO e a
export const liftIO = m => EitherIO(m.map(Either.Right))

// runEitherIO :: EitherIO e a -> Either e a
export const runEitherIO = m => runEitherT(m).runIO()

Updates to our program

import { EitherIO, liftEither, liftIO, runEitherIO } from './EitherIO'

// ...

// prog :: () -> Either Error String
const prog = () =>
  runEitherIO(EitherIO(read('#app'))
    .chain(R.compose(liftIO, write('Hello world'))))

either (throwError, console.log) (prog())

Runnable demo using EitherT

Here's the runnable code using EitherT: https://www.webpackbin.com/bins/-Kh8S2NZ8ufBStUSK1EU

like image 58
Mulan Avatar answered Nov 04 '22 02:11

Mulan


You could create a helper function that will conditionally chain with another IO producing function if the given predicate returns true. If it returns false it will produce an IO ().

// (a → Boolean) → (a → IO ()) → a → IO ()
const ioWhen = curry((pred, ioFn, val) =>
  pred(val) ? ioFn(val) : IO(() => void 0))

const $ = document.querySelector.bind(document)

const read = selector => 
  IO(() => $(selector))

const write = text => domNode =>
  IO(() => domNode.innerHTML = text)

const prog = read('#app').chain(
  ioWhen(node => node != null, write('Hello world'))
)

prog.runIO();
like image 2
Scott Christopher Avatar answered Nov 04 '22 00:11

Scott Christopher