loading...

Basic monads in Javascript

rametta profile image Jason Updated on ・5 min read

I'm going to explain some common monads that you can start using in your javascript today. Monads will help make your code easier to read, more maintainable and most importantly - safer.

Maybe

The Maybe monad is used for dealing with nullable data. Often we like to process data in javascript, like formatting, doing calculations, filtering and sorting. But often we need to make sure the data is there before doing anything. This is where Maybe can help.

I'm going to be using a small friendly helper library called Pratica for providing an implementation of the monads in this article.

Let's take a look at a snippet that can benefit from the Maybe monad.

const data = 'Hello my name is Jason'

if (data) {
  console.log(data.toUpperCase()) // HELLO MY NAME IS JASON
}

Now lets see how that can be refactored with a Maybe.

import { Maybe } from 'pratica'

Maybe('Hello my name is Jason')
  .map(data => data.toUpperCase())
  .cata({
    Just: data => console.log(data), // HELLO MY NAME IS JASON
    Nothing: () => console.log('No data available')
  })

See we don't need to check if the data exists, because Maybe will automatically not run any functions afterwards if the data is null. Avoiding error's like Uncaught TypeError: Cannot read property 'toUpperCase' of undefined

Now you might not see the advantage right away, but this isn't where Maybe shines. Let's look at another example with more steps.

// Step 1: Filter cool people
// Step 2: Find the first cool person
// Step 3: Log their uppercased name if there is one

const data = [
  { name: 'Jason', level: 7, cool: true },
  { name: 'Blanche', level: 8, cool: false }
]

if (data) {
  const coolPeople = data.filter(person => person.cool)
  if (coolPeople) {
    const firstCoolPerson = coolPeople[0]

    if (firstCoolPerson && firstCoolPerson.name) {
      console.log(firstCoolPerson.name.toUpperCase()) // JASON
    }
  }
}

Now let's see the Maybe alternative.

import { Maybe } from 'pratica'

Maybe(data)
  .map(people => people.filter(person => person.cool))
  .map(people => people[0])
  .map(person => person.name)
  .map(name => name.toUpperCase())
  .cata({
    Just: data => console.log(data), // JASON
    Nothing: () => console.log('No data available')
  })

If data was actually null or undefined, then none of the .map functions would run and the Nothing function would be executed in the cata.

But let's say we also wanted to return a default value if the data was null. Then we can use the .default() method.

import { Maybe } from 'pratica'

Maybe(null)
  .map(people => people.filter(person => person.cool))
  .map(people => people[0])
  .map(person => person.name)
  .map(name => name.toUpperCase())
  .default(() => 'No cool people yo')
  .cata({
    Just: data => console.log(data), // No cool people yo
    Nothing: () => console.log('No data available')
  })

Wow such clean, much flat.

Result

So we learned that the Maybe monad is good for dealing with nullable data, but what if we want to check the value of the data and do different things depending on the values.

Enter the Result monad (or sometimes called the Either monad).

Result is used for "branching" your logic. Let's take a look at an example without Result first.

const person = { name: 'Jason', level: 7, cool: true }

if (person.level === 7) {
  console.log('This person is level 7, ew')
} else {
  console.error('This person is some other level, but not 7')
}

Ok, now with Result.

import { Ok, Err } from 'pratica'

const person = { name: 'Jason', level: 7, cool: true }

const lvl = person.level === 7
  ? Ok('This person is level 7, ew')
  : Err('This person is some other level, but not 7')

lvl.cata({
  Ok: msg => console.log(msg), // This person is level 7, ew
  Err: err => console.log(err) // This person is some other level, but not 7
})

Humm, I don't see the point of this. What is Ok and Err? How is this better?

Let's do one more example before explaining it.

In this example, we'll have some data we need to validate before proceeding.

const data = {
  first: 'Jason',
  level: 85,
  cool: true,
  shirt: {
    size: 'm',
    color: 'blue',
    length: 90,
    logo: {
      color1: '#abc123',
      color2: '#somehexcolor'
    }
  }
}

if (data) {
  if (data.shirt) {
    if (data.shirt.logo) {
      if (data.shirt.logo.color1 !== 'black') {

        // Color1 is valid, now lets continue
        console.log(data.shirt.logo.color1)

      } else {
        console.error ('Color1 is black')
      }
    } else {
      console.error ('No logo')
    }
  } else {
    console.error ('No shirt')
  }
} else {
  console.error ('No data')
}

That looks a bit messy. Let's see how we can improve that with Result.

import { Ok, Err } from 'pratica'

const hasData = data => data
  ? Ok (data.shirt)
  : Err ('No data')

const hasShirt = shirt => shirt
  ? Ok (shirt.logo)
  : Err ('No shirt')

const hasLogo = logo => logo
  ? Ok (logo.color1)
  : Err ('No logo')

const isNotBlack = color => color !== 'black'
  ? Ok (color)
  : Err ('Color is black')

hasData (data2)
  .chain (hasShirt)
  .chain (hasLogo)
  .chain (isNotBlack)
  .cata ({
    Ok: color => console.log(color), // #abc123
    Err: msg => console.log(msg)
  })

Interesting, it's a lot flatter, but I still don't understand what's going on.

Ok, here's what's happening.

We start with the hasData function. That takes the initial data that needs to be validated and returns the next data that needs to be validated, but returns it wrapped inside the Result monad, more specifically, the Ok or the Err type. Both of those are what makes the Result monad, and those are how our application will branch the logic.

Why is there .chain() for every line?

Well each function is returning either an Ok or an Err data type. But every function is also expecting it's input to be just data, and not data wrapped inside of a monad. So calling chain on each function will unwrap the data from the monad so the function can read what's inside.

Why is this better?

Well, better is subjective, but in functional programming this is considered better because it pushes the IO (IO being the console logging statements) to the edges of the program. That means that there are more pure functions that can be unit tested and don't have IO mixed inside of them. Having IO inside of pure functions don't make them pure anymore, which means they would be harder to unit test and be a source of bugs. Console logging is not a big deal in javascript, but if the IO was making a network request, then this type of programming makes a big difference, because all logic/validation would be independent of IO and easier to test and maintain.

So those are 2 popular monads you can start using today.

This is my first article of dev.to so let me know what you think in the comments!

If you'd like to learn more about monads, check out these cool articles and libraries.

Posted on Sep 3 '18 by:

rametta profile

Jason

@rametta

Software Developer in Montreal, Canada.

Discussion

markdown guide
 

This looks a excellent library; looking forward to using this. Could you help with the following example?

const data = [
  { wrongName: 'Jason', level: 7, cool: true },
  { wrongName: 'Blanche', level: 8, cool: false }
]

Maybe(data)
  .map(people => people.filter(person => person.cool))
  .map(people => people[0])
  .map(person => person.name)
  .map(name => name.toUpperCase())
  .cata({
    Just: data => console.log(data), // JASON
    Nothing: () => console.log('No data available')
  })

this appears to give me a Uncaught TypeError: Cannot read property 'toUpperCase'... but I imagined it might deal with the error in a similar way to Promises

 

For this example the following would be better if you weren't sure if some fields would be available

const data = [
  { wrongName: 'Jason', level: 7, cool: true },
  { wrongName: 'Blanche', level: 8, cool: false }
]

Maybe(data)
  .map(people => people.filter(person => person.cool))
  .chain(head)
  .chain(get(['name']))
  .map(name => name.toUpperCase())
  .cata({
    Just: data => console.log(data), // JASON
    Nothing: () => console.log('No data available')
  })
 

It seems to looks better with Ramda, isn't it:

const data = [
  { wrongName: 'Jason', level: 7, cool: true },
  { wrongName: 'Blanche', level: 8, cool: false }
];

Maybe(data)
  .map(filter(propEq('cool', true)))
  .map(head)
  .map(prop('name'))
  .map(toUpper)
  .cata({
    Just: console.log
    Nothing: () => console.log('No data available')
  })

PS: thanks a lot for the article

This isn't exactly the same because Ramdas prop() does not return a Maybe, so if that prop did not exist then ramda would return an undefined, which will cause problems down the line. Same with head()

You are totally right. Sorry for my mistake.

 

This is so cool.

FYI if you don't quite get the point this article shows, give a taste on Haskell. The monad thing is from Haskell which is an extremely neat programing language.

 

Thanks, I wonder what the implications are for performance?

 

Typically pretty negligible. The Maybe monad just checks the value against null and undefined and calls the callback function. So, not much happening.

 

Yes, but you also create a new callback function for each step, and the gc should clean them up.

 

Thanks so much for this Jason! I've always felt FP was a bit over my head but this is a great little example and explanation. Thanks again 👍

 

Thanks, really cool example.
A preference for the lib apart from Oncha ?

 

Thanks! I prefer pratica because I wrote it :)

 

Great stuff. I'll share this with my brother-in-law because it'll do a great job at explaining some FP fundamentals for us to start another conversation with.

 

I find using Result very neat for modeling AND logic, but what about if I need to branch my logic and provide two different Ok results depending on OR logic?

 

That should be no problem either, instead of returning Result's you can use values, example:

Ok('some value')
  .map(x => x.length > 4 ? 'long' : 'short') // .map() will stay an 'Ok'
  .map(x => x.toUpperCase())
  .chain(x => x === 'LONG' ? Err('too long') : Ok(x)) // switch to Err branch if you want
  .cata(...)