Understanding the Maybe Type

8 min read

Imagine a scenario where invoking a function could fail if an argument passed to the function is the wrong data type (i.e. a string instead of a number), null, or undefined. You can guard against errors by checking your input with typeof or checking for null or undefined. This adds code and can make reading and refactoring more difficult. This sort of scenario is exactly what the Maybe type can help us avoid. The Maybe offers a way to encapsulate the safety that these conditionals bring without making the code too "noisy".

In this article, we'll build up to using Maybe in a small example before we expand on our understanding to use the Maybe in more ways in the upcoming articles in this series.

The Problem

We'll start with some small utility functions to use in the upcoming examples. In a utils.js file, we'll start with an inc function that increments a provided number.

const inc = (n) => n + 1

If we run the inc function passing in a number, we'll get the expected result of that number + 1.

import { inc } from './utils'

const input = 2

const result = inc(input)

console.log(result) // 3

The problems start to arise when we pass in a non-numeric value, such as the string '8'

import { inc } from './utils'

const input = '8'

const result = inc(input)

console.log(result) // '81'

In this case, the string on the left hand side of the + operator led to the 1 inside inc to be coerced to a string and then it was concatenated onto our supplied value.

If we pass undefined instead, we'll end up with NaN as a result.

import { inc } from './utils'

const input = undefined

const result = inc(input)

console.log(result) // NaN

And finally, if we pass null, we'll end up with 1 as a result.

import { inc } from './utils'

const input = null

const result = inc(input)

console.log(result) // 1

All of these are problematic in their own way. Ending up with a NaN or a string where you expect a number is sure to lead to an error when you use this value for future operations. The result of 1 for the null input could be fine, but could lead to an even stranger bug when your math is off, but there is no error. Maybe you think your input is making it into the function, but it's becoming null at some point. This is the sort of bug where you might not think to look for that, and you'll look in other places to see why the math is off.

Sure, this example is small and it probably wouldn't take long to work this out, but in the context of this being a single step in a larger operation, this sort of thing can eat up valuable time and patience.

Adding Some Guards

Let's start with a standard approach, we'll add some conditional logic in here and if our input is invalid, we'll default to 0 for our return value.

Let's start off by putting this at the point where we invoke the inc utility. We'll add in a ternary and if the typeof input is a number, we'll pass it to inc and if not, we'll skip the invocation and return our default value of 0.

import { inc } from './utils'

const input = 2

const result = typeof input === 'number' ? inc(input) : 0

console.log(result) // 3

We don't want to do this every time we invoke this function, that'll make our code really noisy and we could easily forget to add it in all the places. We could move this logic into the utility function, but that only works if we always want the same default and if we have direct control over the code. Instead, let's take a look at how Maybe can help us out.

Bringing in the Maybe Type

For our examples, we'll be using the Maybe type as implemented by the Crocks ADT library. You can install it into a project via npm with

npm install crocks

Once it's installed, you can import Maybe with

import Maybe from 'crocks/Maybe'

With crocks installed and the Maybe type imported, let's look at the basics.

Nothing & Just

A Maybe is a union type that can be one of two underlying types. Maybe can be a Nothing which means there is no value, or a Just x (read "Just of x") that holds a value (x). The type can be represented as:

// Maybe = Nothing | Just x

If we want a maybe that holds a value (a Just x) we can construct that with the Just constructor method on Maybe.

const input = Maybe.Just(2)

The value of input will now be a Just that holds the value of 2. We can see this by logging out input and calling toString().

const input = Maybe.Just(2)

console.log(input.toString()) // Just 2

By constructing this Just, we've wrapped our 2 away inside this container. This means we can't simply pass it into the inc utility since it expects a number. Instead, what we'll do is use methods available on the Just type. In order to invoke a function on the contained value, we'll use the map method.

import Maybe from 'crocks/Maybe'
import { inc } from './utils'

const input = Maybe.Just(2)

const result = input.map(inc)

console.log(result.toString()) // Just 3

You can imagine that map here unwraps the value from the Just, passes it to the function (inc in this case), then wraps the value again so we can continue to operate on the value with the safety of the Maybe intact.

At this point, you might be wondering where the safety is. We'll get there, but first let's talk about the Nothing piece of this union type. Instead of constructing a Just with a value for our input, let's swap that out with a Nothing() constructor method.

import Maybe from 'crocks/Maybe'
import { inc } from './utils'

const input = Maybe.Nothing()

const result = input.map(inc)

console.log(result.toString()) // Nothing

Our result here is a Nothing. Inside a Maybe instance, a Just will invoke the function passed to map, a Nothing will skip it, avoiding any potential errors or unintended results.

Let's see this in action. Let's update the function passed to map so we can log when it is invoked. We'll add an arrow function, console.log and pass the argument through to inc so it will log to the console and continue to work as expected.

import Maybe from 'crocks/Maybe'
import { inc } from './utils'

const input = Maybe.Nothing()

const result = input.map((n) => console.log('running map', n) || inc(n))

console.log(result.toString()) // Nothing

Since console.log returns undefined, that || will evaluate the expression on the right and result in inc being called and its result being returned in the map.

When we run this, we'll get the Nothing result and should see no output from the function in map. Now let's replace that Maybe.Nothing() with Maybe.Just(3).

import Maybe from 'crocks/Maybe'
import { inc } from './utils'

const input = Maybe.Just(3)

const result = input.map((n) => console.log('running map', n) || inc(n))

console.log(result.toString()) // Just 4

This will result in a Just 4 result as well as running map 3 being output in the console when map runs.

Creating a safeNum Function

Now that we've seen how things like map behave in a Maybe based on the underlying type, the next step is to take an input that could be valid or invalid and create an instance of the correct underlying type. For this example, we'll make a safeNum function.

We're going to duplicate some logic we used earlier by checking our data type with typeof but we'll use this as a constructor for a Maybe this time.

const safeNum = (val) =>
  typeof val === 'number' ? Maybe.Just(val) : Maybe.Nothing()

Now we can use safeNum to create our input value.

import Maybe from 'crocks/Maybe'
import { inc } from './utils'

const safeNum = (val) =>
  typeof val === 'number' ? Maybe.Just(val) : Maybe.Nothing()

const input = safeNum(3)

const result = input.map((n) => console.log('running map', n) || inc(n))

console.log(result.toString()) // Just 4

If we pass in a number, we'll see the console.log from our map and a result of Just 4.

If we instead pass in a non-number value like a string, null, undefined, [], or {}, we will not see the console.log and the result will be a Nothing.

import Maybe from 'crocks/Maybe'
import { inc } from './utils'

const safeNum = (val) =>
  typeof val === 'number' ? Maybe.Just(val) : Maybe.Nothing()

const input = safeNum(null)

const result = input.map((n) => console.log('running map', n) || inc(n))

console.log(result.toString()) // Nothing

Wrap Up

This is just the start of being able to take advantage of the Maybe type. In upcoming articles, we'll dive into more methods and scenarios where having our values wrapped up in a Maybe can help keep our code safer and more predictable.