javascriptfunctional-programmingmonadsramda.jsfantasyland

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


Solution

  • 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