/maybe

The Maybe Monad in R

Primary LanguageROtherNOASSERTION

maybe

CRAN status R-CMD-check Codecov test coverage metacran downloads R-CMD-check

Overview

The maybe type represents the possibility of some value or nothing. It is often used instead of throwing an error or returning an undefined value like NA or NULL. The advantage of using a maybe type is that the functions which work with it are both composable and require the developer to explicitly acknowledge the potential absence of a value, helping to avoid unexpected behavior.

Installation

You can install the released version of maybe from CRAN with:

install.packages("maybe")

And the development version from GitHub with:

# install.packages("remotes")
remotes::install_github("armcn/maybe")

Usage

The following example shows how the maybe package can be used to create a safe data processing pipeline.

library(maybe)

safe_filter <- maybe(dplyr::filter, ensure = not_empty)
safe_mean <- maybe(mean, ensure = not_undefined)
safe_pull <- maybe(dplyr::pull)

mean_mpg_of_cyl <- function(.cyl) {
  mtcars %>% 
    safe_filter(cyl == .cyl) %>% 
    and_then(safe_pull, mpg) %>% 
    and_then(safe_mean) %>% 
    with_default(0)
}

mean_mpg_of_cyl(8L)
#> [1] 15.1
mean_mpg_of_cyl(100L)
#> [1] 0

Here is an example of working with data stored in JSON format.

library(purrr)

parse_numbers <- 
  function(x) filter_map(x, maybe(as.numeric))

safe_first <- 
  maybe(function(x) x[[1]], ensure = not_empty)

sum_first_numbers <- function(json) {
  jsonlite::fromJSON(json) %>%
    filter_map(compose(safe_first, parse_numbers)) %>% 
    perhaps(reduce, default = 0)(`+`)
}

sum_first_numbers('{"a": [], "b": [1, 2.2, "three"], "c": [3]}')
#> [1] 4
sum_first_numbers('{}')
#> [1] 0
sum_first_numbers('1, 2, 3')
#> [1] 0

The maybe type

Maybe values can be used to model computations that may fail or have undefined outputs. For example, dividing by zero is mathematically undefined but in many programming languages, including R, infinity is returned. If it is not properly accounted for this may cause unexpected behavior later in the program. The maybe type can be used to improve the safety of the divide function.

divide <- function(a, b) {
  a / b
}

safe_divide <- function(a, b) {
  if (b == 0) nothing() else just(a / b)
}

divide(10, 2)
#> [1] 5
safe_divide(10, 2)
#> Just
#> [1] 5

divide(10, 0)
#> [1] Inf
safe_divide(10, 0)
#> Nothing

safe_divide(10, 2) returns Just 5 and safe_divide(10, 0) returns Nothing. These are the two possible values of the maybe type. It can be Just the value, or it can be Nothing, the absence of a value. For the value to be used as an input to another function you need to specify what will happen if the function returns Nothing.

This can be done using the with_default function. This function will return the value contained in the Just, or if it is Nothing it will return the default. Think of a maybe value as a container. In this container can be Just the value or Nothing. To use the contained value in a regular R function you need to unwrap it first.

safe_divide(10, 2)
#> Just
#> [1] 5
safe_divide(10, 2) %>% with_default(0)
#> [1] 5

safe_divide(10, 0)
#> Nothing
safe_divide(10, 0) %>% with_default(0)
#> [1] 0

Chaining maybe values

This may seem tedious to rewrite functions to return maybe values and then specify a default value each time. This is where the maybe chaining functions become useful.

maybe_map allows a regular R function to be evaluated on a maybe value. maybe_map, often called fmap in other languages, reaches into the maybe value, applies a function to the value, then re-wraps the result in a maybe. If the input is a Just value, the return value of maybe_map will also be a Just. If it is Nothing the return value will be Nothing.

just(9) %>% maybe_map(sqrt)
#> Just
#> [1] 3
nothing() %>% maybe_map(sqrt)
#> Nothing

What if we wanted to chain multiple “safe” functions (functions that return maybe values) together? The function and_then, often called bind in other languages, works similarly to maybe_map except the function provided must return a maybe value.

safe_max <- function(a) {
  if (length(a) == 0) nothing() else just(max(a))
}

safe_sqrt <- function(a) {
  if (a < 0) nothing() else just(sqrt(a))
}

just(1:9) %>%
  and_then(safe_max) %>%
  and_then(safe_sqrt)
#> Just
#> [1] 3

nothing() %>%
  and_then(safe_max) %>%
  and_then(safe_sqrt)
#> Nothing

Creating maybe functions

The maybe package provides another way to create functions that return maybe values. Instead of rewriting the function to return maybe values we can wrap it in the maybe function. This will modify the function to return Nothing on an error or warning.

A predicate function (a function that returns TRUE or FALSE) can be provided as an argument to assert something about the return value. If the predicate returns TRUE then a Just value will be returned, otherwise it will be Nothing.

safe_max <- maybe(max)
safe_sqrt <- maybe(sqrt, ensure = not_infinite)

safe_max(1:9) %>% and_then(safe_sqrt)
#> Just
#> [1] 3
safe_max("hello") %>% and_then(safe_sqrt)
#> Nothing

This pattern of modifying a function with the maybe function and then setting a default value is so common that there is a shortcut, perhaps. The default value is set with the default parameter. This function will always return a regular R value, never maybe values.

perhaps_max <- perhaps(max, ensure = is.numeric, default = 0)

perhaps_max(1:9) 
#> [1] 9
perhaps_max("hello") 
#> [1] 0

Predicates

Multiple predicates can be combined with the and/or functions.

safe_sqrt <- maybe(sqrt, ensure = and(not_nan, not_empty))

safe_sqrt(9)
#> Just
#> [1] 3
safe_sqrt(-1)
#> Nothing

Predefined combinations are also provided such as not_undefined, which ensures that the output is not any of NULL, NA, NaN, -Inf, or Inf.

safe_mean <- maybe(mean, ensure = not_undefined)

safe_mean(c(1, 2, 3))
#> Just
#> [1] 2
safe_mean(c(NA, 2, 3))
#> Nothing

Function names

The names of functions maybe_map, and_then, maybe_flatten, and with_default are different from the traditional names used for these functions in other functional programming languages. If you would like to use the more traditional names aliases are provided.

  • fmap == maybe_map
  • bind == and_then
  • join == maybe_flatten
  • from_maybe == with_default

Inspiration / Prior work