Use lambdas and combinators to improve your API

If your API overflows with Boolean parameters, this is usually a bad smell.

Consider the following function call for example:

toContactInfoList(csv, true, true)

When looking at this snippet of code it is not very clear what kind of effect the two Boolean parameters will have exactly. In fact, we would probably be without a clue.

We have to inspect the documentation or at least the parameter names of the function declaration to get a better idea. But still, this doesn’t solve all of our problems.

The more Boolean parameters there are, the easier it will be for the caller to mix them up. We have to be very careful.

Moreover, functions with Boolean parameters must have conditional logic like if or case statements inside. With a growing number of conditional statements, the number of possible execution paths will grow exponentially. It will become more difficult to reason about the implementation code.

Can we do better?

Sure we can. Lambdas and combinators come to the rescue and I’m going to show this with a simple example, a refactoring of the function from above.

This post is based on a great article by John A De Goes, Destroy All Ifs — A Perspective from Functional Programming.

I’m going to take John’s ideas that he backed up with PureScript examples and present how the same thing can be elegantly achieved in Scala.

Example: Extracting names and emails from a CSV file

Let’s take an example that might be a bit artificial, but should not be too complex to demonstrate the techniques to eliminate the pitfalls of conditionals.

The function toContactInfoList from the opening example parses the contents of a CSV file into a sequence of ContactInfo.

The Boolean parameters control whether rows containing either an empty name or an empty email should be omitted from the result or not.

An implementation of this function might look like this:

def toContactInfoList(
  csv: Seq[String],
  nameRequired: Boolean,
  emailRequired: Boolean): Seq[ContactInfo] = {
  csv
    .map(_.split(';'))
    .map(tokens => 
      (tokens.headOption.getOrElse(""), tokens.drop(1).headOption.getOrElse("")))
    .flatMap {
      case (name, email) =>
        if ((name == "" && nameRequired) || (email == "" && emailRequired)) {
          None
        } else {
          Some(ContactInfo(name, email))
        }
    }
}

Inversion of control

Boolean parameters can be seen as a serialization protocol.

At the caller site, we serialize our intention into a bit (or other data value), pass it to the function, and then the function deserializes the value into an intention (a chunk of code). — John A De Goes

The serialization on the call side and the deserialization on the implementation side are spots where things can potentially go wrong.

Therefore the first step to improve the design is to get rid of these Boolean parameters by replacing them with a lambda.

This way the caller no longer has to encode their intentions as Boolean values. Instead the caller can now pass a function, the actual intention, to the API.

Here is a new version of our original function where the Boolean parameters are replaced by a lambda. Out of convenience we will use a type alias for the lambda:

type Converter = (String, String) => Option[ContactInfo]

def toContactInfoList(
  csv: Seq[String],
  convert: Converter): Seq[ContactInfo] = {
  csv
    .map(_.split(';'))
    .map(tokens =>
      (tokens.headOption.getOrElse(""), tokens.drop(1).headOption.getOrElse("")))
    .flatMap { case (name, email) => convert(name, email) }
}

With an implementation like this the caller has to construct the converter parameter themselves, e.g. like this:

def noEmptyNameOrEmail: Converter = {
  case ("", _) | (_, "") =>
    None
  case (name, email) =>
    Some(ContactInfo(name, email))
}

toContactInfoList(csv, noEmptyNameOrEmail)

It can be good to give the caller a greater amount of control like this. On the other hand, this is not very convenient most of the time, and also error prone.

So, we are only half way there.

Exposing available options as combinators

For a better usability the API should expose ready made implementations (in the form of functions) that the caller can choose to pass back into the API method.

In our example these functions have the type Converter.

Let’s start with the simplest converter:

def makeContactInfo: Converter = {
  case (name, email) => Some(ContactInfo(name, email))
}

Passing this converter (like so: toContactInfoList(csv, makeContactInfo)) is equivalent to calling the original version like this: toContactInfoList(csv, false, false).

Now, to enable the caller to specify the other options, we will implement combinators. These combinators are higher-order functions that take a Converter as input and return a new Converter, therefore have the type: Converter => Converter. The returned converter combines the behavior of the converter that was given as an input parameter with a new behavior.

A combinator that will omit empty emails looks like this:

def noEmptyEmail: Converter => Converter = {
  converter => {
    case (_, "") =>
      None
    case (name, email) =>
      converter(name, email)
  }
}

The combinator that will omit empty names is very similar:

def noEmptyName: Converter => Converter = {
  converter => {
    case ("", _) =>
      None
    case (name, email) =>
      converter(name, email)
  }
}

Now these functions can be combined like this:

noEmptyEmail(noEmptyName(makeContactInfo))

Enabling a nicer syntax

For better readability and to enable auto-completion which makes it easier for the caller to find available combinators, we will use Scala’s implicit classes:

implicit class ConverterSyntax(convert: Converter) {
  def noEmptyName = ContactInfoConverters.noEmptyName(convert)
  def noEmptyEmail = ContactInfoConverters.noEmptyEmail(convert)
}

The syntax is much nicer now:

makeContactInfo.noEmptyEmail.noEmptyName

Conclusion

Finally, a call of the new version of the API method looks like this:

toContactInfoList(csv, makeContactInfo.noEmptyEmail.noEmptyName)

Not only is it much easier than before to reason about what’s going on, we also eliminated the potentials for errors related to encoding and decoding of intent. Also the caller can now provide custom converters.

Moreover, with the new design it is possible to extend the API by adding more combinators in the sense of the Open-Closed Principle.

Note that in Scala we don’t have the same guaranties for purity and control of side effects as in PureScript or Haskell.

However, I definitely think the benefits described in this post make it worthwhile giving these techniques a try. What do you think? Let me know in the comments!

  • Wojciech Grajewski

    Great post, easy to follow and immediately useful in practice (although I guess in some scenarios this technique wouldn’t be so readily applicable)! The examples made everything very understandable

    • Thanks for the nice feedback. Yes, I think there is not one recipe for all sorts of situations. The other day I had a function I wanted to improve and I was like: “Ok, I’m going to apply this technique now”. Then I came up with a totally different solution that was much simpler. I guess the lesson is that Boolean parameters and the like (also algebraic data types) might be an indicator that the design could be improved by applying one technique or another. Lambdas and combinators is just one possibility.