Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Monad transformers explained in Javascript?

I'm having a hard time understanding monad transformers, partly because most examples and explanations use Haskell.

Could anyone give an example of creating a transformer to merge a Future and an Either monad in Javascript and how it can be used.

If you can use the ramda-fantasy implementation of these monads it would be even better.

like image 932
Marcelo Lazaroni Avatar asked Nov 30 '22 15:11

Marcelo Lazaroni


1 Answers

Rules first

First we have the Natural Transformation Law

  • Some functor F of a, mapped with function f, yields F of b, then naturally transformed, yields some functor G of b.
  • Some functor F of a, naturally transformed yields some functor G of a, then mapped with some function f, yields G of b

Choosing either path (map first, transform second, or transform first, map second) will lead to the same end result, G of b.

natural transformation law

nt(x.map(f)) == nt(x).map(f)

Getting real

Ok, now let's do a practical example. I'm gonna explain the code bit-by-bit and then I'll have a complete runnable example at the very end.

First we'll implement Either (using Left and Right)

const Left = x => ({
  map: f => Left(x),
  fold: (f,_) => f(x)
})

const Right = x => ({
  map: f => Right(f(x)),
  fold: (_,f) => f(x),
})

Then we'll implement Task

const Task = fork => ({
  fork,
  // "chain" could be called "bind" or "flatMap", name doesn't matter
  chain: f =>
    Task((reject, resolve) =>
      fork(reject,
           x => f(x).fork(reject, resolve)))
})
Task.of = x => Task((reject, resolve) => resolve(x))
Task.rejected = x => Task((reject, resolve) => reject(x))

Now let's start defining some pieces of a theoretical program. We'll have a database of users where each user has a bff (best friend forever). We'll also define a simple Db.find function that returns a Task of looking up a user in our database. This is similar to any database library that returns a Promise.

// fake database
const data = {
  "1": {id: 1, name: 'bob', bff: 2},
  "2": {id: 2, name: 'alice', bff: 1}
}

// fake db api
const Db = {
  find: id =>
    Task((reject, resolve) => 
      resolve((id in data) ? Right(data[id]) : Left('not found')))
}

OK, so there's one little twist. Our Db.find function returns a Task of an Either (Left or Right). This is mostly for demonstration purposes, but also could be argued as a good practice. Ie, we might not consider user-not-found scenario an error, thus we don't want to reject the task – instead, we gracefully handle it later by resolving a Left of 'not found'. We might use reject in the event of a different error, such as a failure to connect to the database or something.


Making goals

The goal of our program is to take a given user id, and look up that user's bff.

We're ambitious, but naïve, so we first try something like this

const main = id =>
  Db.find(1) // Task(Right(User))
    .map(either => // Right(User)
      either.map(user => // User
        Db.find(user.bff))) // Right(Task(Right(user)))

Yeck! a Task(Right(Task(Right(User)))) ... this got out of hand very quickly. It will be a total nightmare working with that result...


Natural transformation

Here comes our first natural transformation eitherToTask:

const eitherToTask = e =>
  e.fold(Task.rejected, Task.of)

// eitherToTask(Left(x)) == Task.rejected(x)
// eitherToTask(Right(x)) == Task.of(x)

Let's watch what happens when we chain this transformation on to our Db.find result

const main = id =>
  Db.find(id) // Task(Right(User))
    .chain(eitherToTask) // ???
    ...

So what is ???? Well Task#chain expects your function to return a Task and then it squishes the current Task, and the newly returned Task together. So in this case, we go:

// Db.find           // eitherToTask     // chain
Task(Right(User)) -> Task(Task(User)) -> Task(User)

Wow. This is already a huge improvement because it's keeping our data much flatter as we move through the computation. Let's keep going ...

const main = id =>
  Db.find(id) // Task(Right(User))
    .chain(eitherToTask) // Task(User)
    .chain(user => Db.find(user.bff)) // ???
    ...

So what is ??? in this step? We know that Db.find returns Task(Right(User) but we're chaining, so we know we'll squish at least two Tasks together. That means we go:

// Task of Db.find         // chain
Task(Task(Right(User))) -> Task(Right(User))

And look at that, we have another Task(Right(User)) which we already know how to flatten. eitherToTask!

const main = id =>
  Db.find(id) // Task(Right(User))
    .chain(eitherToTask) // Task(User)
    .chain(user => Db.find(user.bff)) // Task(Right(User))
    .chain(eitherToTask) // Task(User) !!!

Hot potatoes! Ok, so how would we work with this? Well main takes an Int and returns a Task(User), so ...

// main :: Int -> Task(User)
main(1).fork(console.error, console.log)

It's really that simple. If Db.find resolves a Right, it will be transformed to a Task.of (a resolved Task), meaning the result will go to console.log – otherwise, if Db.find resolves a Left, it will be transformed to a Task.rejected (a rejected Task), meaning the result will go to console.error


Runnable code

// Either
const Left = x => ({
  map: f => Left(x),
  fold: (f,_) => f(x)
})

const Right = x => ({
  map: f => Right(f(x)),
  fold: (_,f) => f(x),
})

// Task
const Task = fork => ({
  fork,
  chain: f =>
    Task((reject, resolve) =>
      fork(reject,
           x => f(x).fork(reject, resolve)))
})

Task.of = x => Task((reject, resolve) => resolve(x))
Task.rejected = x => Task((reject, resolve) => reject(x))

// natural transformation
const eitherToTask = e =>
  e.fold(Task.rejected, Task.of)

// fake database
const data = {
  "1": {id: 1, name: 'bob', bff: 2},
  "2": {id: 2, name: 'alice', bff: 1}
}

// fake db api
const Db = {
  find: id =>
    Task((reject, resolve) => 
      resolve((id in data) ? Right(data[id]) : Left('not found')))
}

// your program
const main = id =>
  Db.find(id)
    .chain(eitherToTask)
    .chain(user => Db.find(user.bff))
    .chain(eitherToTask)

// bob's bff
main(1).fork(console.error, console.log)
// alice's bff
main(2).fork(console.error, console.log)
// unknown user's bff
main(3).fork(console.error, console.log)

Attribution

I owe almost this entire answer to Brian Lonsdorf (@drboolean). He has a fantastic series on Egghead called Professor Frisby Introduces Composable Functional JavaScript. Quite coincidentally, the example in your question (transforming Future and Either) is the same example used in his videos and in this code in my answer here.

The two about natural transformations are

  1. Principled type conversions with natural transformations
  2. Applying natural transformations in everyday work

Alternate implementation of Task

Task#chain has a little bit of magic going on that's not immediately apparent

task.chain(f) == task.map(f).join()

I mention this as a side note because it's not particularly important for considering the natural transformation of Either to Task above. Task#chain is enough for demonstrations, but if you really want to take it apart to see how everything is working, it might feel a bit unapproachable.

Below, I derive chain using map and join. I'll put a couple of type annotations below that should help

const Task = fork => ({
  fork,
  // map :: Task a => (a -> b) -> Task b
  map (f) {
    return Task((reject, resolve) =>
      fork(reject, x => resolve(f(x))))
  },
  // join :: Task (Task a) => () -> Task a
  join () {
    return Task((reject, resolve) =>
      fork(reject,
           task => task.fork(reject, resolve)))
  },
  // chain :: Task a => (a -> Task b) -> Task b
  chain (f) {
    return this.map(f).join()
  }
})

// these stay the same
Task.of = x => Task((reject, resolve) => resolve(x))
Task.rejected = x => Task((reject, resolve) => reject(x))

You can replace the definition of the old Task with this new one in the example above and everything will still work the same ^_^


Going Native with Promise

ES6 ships with Promises which can function very similarly to the Task we've implemented. Of course there's heaps of difference, but for the point of this demonstration, using Promise instead of Task will result in code that almost looks identical to the original example

The primary differences are:

  • Task expects your fork function parameters to be ordered as (reject, resolve) - Promise executor function parameters are ordered as (resolve, reject) (reverse order)
  • we call promise.then instead of task.chain
  • Promises automatically squish nested Promises, so you don't have to worry about manually flattening a Promise of a Promise
  • Promise.rejected and Promise.resolve cannot be called first class – the context of each needs to be bound to Promise – eg x => Promise.resolve(x) or Promise.resolve.bind(Promise) instead of Promise.resolve (same for Promise.reject)

// Either
const Left = x => ({
  map: f => Left(x),
  fold: (f,_) => f(x)
})

const Right = x => ({
  map: f => Right(f(x)),
  fold: (_,f) => f(x),
})

// natural transformation
const eitherToPromise = e =>
  e.fold(x => Promise.reject(x),
         x => Promise.resolve(x))

// fake database
const data = {
  "1": {id: 1, name: 'bob', bff: 2},
  "2": {id: 2, name: 'alice', bff: 1}
}

// fake db api
const Db = {
  find: id =>
    new Promise((resolve, reject) => 
      resolve((id in data) ? Right(data[id]) : Left('not found')))
}

// your program
const main = id =>
  Db.find(id)
    .then(eitherToPromise)
    .then(user => Db.find(user.bff))
    .then(eitherToPromise)

// bob's bff
main(1).then(console.log, console.error)
// alice's bff
main(2).then(console.log, console.error)
// unknown user's bff
main(3).then(console.log, console.error)
like image 168
Mulan Avatar answered Dec 04 '22 12:12

Mulan