How to gently introduce an FP library

Zelenya - Feb 1 - - Dev Community

šŸ“¹Ā Hate reading articles? Check out the complementary video, which covers the same content.

You cannot bring a library with a whole new paradigm and convince people to use it out of the blue. But you can bring a new library that delivers value right away (and wait for it to spread like wildfire).

Weā€™ll look at a few functionalities weā€™ve used or seen others use to introduce a first functional dependency into a company.

Weā€™ll use Scala and cats in the examples, but it should be somewhat applicable to other libraries and languages.

Shout out to my colleagues who have done these or supported me on these mischiefs.

How to combine twoĀ maps

Has it ever happened to you that someone gives you a map and then another one, and you have to smoosh them together? For example, maps of scores across different rounds:

val round1 = Map("A-team" -> 2, "B-team" -> 1)
val round2 = Map("B-team" -> 3, "C-team" -> 2, "D-team" -> 1)
Enter fullscreen mode Exit fullscreen mode

If we try something straightforward, like concatenating these maps, the values for the same keys come from the second map (for example, B-team):

round1 ++ round2
// Map(A-team -> 2, B-team -> 3, C-team -> 2, D-team -> 1)
Enter fullscreen mode Exit fullscreen mode

If we donā€™t want to override the values but combine (in this case, add) them instead, we can do this by hand ā€” something like this:

round1.foldLeft(round2){
 case (acc, (k, v)) => acc + (k -> (v + acc.getOrElse(k, 0)))
}

// Map(B-team -> 4, C-team -> 2, D-team -> 1, A-team -> 2)
Enter fullscreen mode Exit fullscreen mode

Or using cats:

import cats.implicits._

round1 |+| round2
// Map(B-team -> 4, C-team -> 2, D-team -> 1, A-team -> 2)
Enter fullscreen mode Exit fullscreen mode

Or:

import cats.implicits._

round1.combine(round2)
// Map(B-team -> 4, C-team -> 2, D-team -> 1, A-team -> 2)
Enter fullscreen mode Exit fullscreen mode

The combine function merges two maps and adds values for the same keys.

On top of that, we can use it with multiple maps:

val round1 = Map("A-team" -> 2, "B-team" -> 1)
val round2 = Map("B-team" -> 3, "C-team" -> 2, "D-team" -> 1)
val round3 = Map("A-team" -> 1, "C-team" -> 2, "D-team" -> 3)

round1 |+| round2 |+| round3
// Map(A-team -> 3, C-team -> 4, B-team -> 4, D-team -> 4)
Enter fullscreen mode Exit fullscreen mode

Or use combineAll, which does the same thing:

List(round1, round2, round3).combineAll
// Map(A-team -> 3, C-team -> 4, B-team -> 4, D-team -> 4)
Enter fullscreen mode Exit fullscreen mode

The best part: combine knows how to combine a lot of things. For example, we can combine maps with lists for values:

val round1List = Map("A-team" -> List(1, 2), "B-team" -> List(3, 4))
val round2List = 
  Map("A-team" -> List(20), "B-team" -> List(30), "C-team" -> List(0))

round1List.combine(round2List)
// Map(A-team -> List(1, 2, 20), B-team -> List(3, 4, 30), C-team -> List(0))
Enter fullscreen mode Exit fullscreen mode

Or even maps with maps with lists for values:

val day1 = Map("round1" -> round1List)
val day2 = Map("round1" -> round1List, "round2" -> round2List)

day1.combine(day2)
// Map(
//  round1 -> Map(A-team -> List(1, 2, 1, 2), B-team -> List(3, 4, 3, 4)),
//  round2 -> Map(A-team -> List(20), B-team -> List(30), C-team -> List(0))
// )
Enter fullscreen mode Exit fullscreen mode

Sorry, got carried away.

Itā€™s simple but quite convenient. And, of course, it doesnā€™t magically work for every type of data and every use-case. You must use supported data types and collections, associative operations, and know what other biases exist.

In other words, why does it sum the integers and doesnā€™t multiply them, for instance?

import cats.implicits._

round1.combine(round2)
// Map(B-team -> 4, C-team -> 2, D-team -> 1, A-team -> 2)
Enter fullscreen mode Exit fullscreen mode

It takes time to get used to it and figure out. And when we do, we get a powerful tool that we can use in wide variety of use-cases, even to multiple integers!

How to accumulate errors

Weā€™ve all seen these forms that validate one thing at a time. You fill it in, and then it says that there is an error; you fix it, and then it says there is another one, and so on.

Luckily, we can use Validated from cats to avoid frustrating our clients.

If we started with a form-validation with eithers that short-circuit (aka fail fast):

type Error = String

case class Score(team: String, score: Int)

def validateNameE(name: String): Either[Error, String] =
  if (name.isEmpty) Left("Name cannot be empty")
  else Right(name)

def validateScoreE(score: Int): Either[Error, Int] =
  if (score <= 0) Left("Score must be greater than zero")
  else Right(score)
Enter fullscreen mode Exit fullscreen mode

Having both invalid name and score returns only one error (the first one):

for
  name  <- validateNameE("")
  score <- validateScoreE(0)
yield Score(name, score)
// Left(Name cannot be empty)
Enter fullscreen mode Exit fullscreen mode

If we replace this with Validated:

import cats.implicits._
import cats.data.Validated

type Errors = List[String]

def validateName(name: String): Validated[Errors, String] =
  if (name.isEmpty) List("Name cannot be empty").invalid
  else name.valid

def validateScore(score: Int): Validated[Errors, Int] =
  if (score <= 0) List("Score must be greater than zero").invalid
  else score.valid
Enter fullscreen mode Exit fullscreen mode

Note that we use Errors instead of Error.

We get both errors:

(validateName(""), validateScore(0)).mapN(Score.apply)
// Invalid(List(Name cannot be empty, Score must be greater than zero))
Enter fullscreen mode Exit fullscreen mode

Because we donā€™t want to short-circuit on the first error, we donā€™t (and canā€™t) use for-comprehension. We use mapN from cats, which weā€™ll come back to later. We can only construct a Score if both the name and score number are valid.


For the record, in real life, List isnā€™t the most efficient collection for concatenations like that ā€” so, we use something more proper, like NonEmptyChain from cats:

import cats.data.NonEmptyChain

type Errors = NonEmptyChain[String]

def validateName(name: String): Validated[Errors, String] =
  if (name.isEmpty) "Name cannot be empty".invalidNec
  else name.validNec

def validateScore(score: Int): Validated[Errors, Int] =
  if (score <= 0) "Score must be greater than zero".invalidNec
  else score.validNec

(validateName(badName), validateScore(badScore)).mapN(Score.apply)
// Invalid(Chain(Name cannot be empty, Score must be greater than zero))
Enter fullscreen mode Exit fullscreen mode

Note that we use Nec-suffixed functions, which stand for NonEmptyChain.

Strings arenā€™t the best for errors either, but anywaysā€¦


TheĀ ValidatedĀ data type is a great tool for handling validation, which we can use when we need to accumulate errors instead of failing fast and reporting one error at a time.

Nobody wants frustrated clients, right? ā€” easy win.

Bonus: tidy extension methods

In previous snippets, we used a few extension methods from cats to convert types into Validated: valid,Ā invalid,Ā validNec,Ā invalidNec. Those are quite simple but convenient:

val x = "Same thing".valid

val y = Validated.Valid("Same thing")
Enter fullscreen mode Exit fullscreen mode

On top of that, cats provide extension methods for options and eithers as well. A little syntax change doesnā€™t justify a whole new dependency. Sure. However, there is one more thing to it:

Some("this")
// Some[String]

import cats.implicits._

"that".some
// Option[String]
Enter fullscreen mode Exit fullscreen mode

These functions return widened type (unlike the standard constructors)

šŸ’” Tbh not sure if it still affects type inference too much in Scala 3, but back in the day, it was pretty annoying.

Bonus: smooshing tuples

Another function weā€™ve seen is mapN. Itā€™s roughly about smooshing tuple values. We can take a tuple of Validated values and construct a Validated Score:

("A-team".valid[Errors], 2.valid[Errors]).mapN(Score.apply)
// Valid(Score(A-team,2))
Enter fullscreen mode Exit fullscreen mode

We can do the same with options:

case class RoundScore(name: String, round: String, score: Int)

("A-team".some, "round1".some, 2.some).mapN(RoundScore.apply)
// Some(Score(A-team, round1, 2))
Enter fullscreen mode Exit fullscreen mode

We can do other stuff, not just constructing; for example, concatenating strings:

("a".some, "b".some, "c".some).mapN(_ ++ _ ++ _)
// Some(abc)
Enter fullscreen mode Exit fullscreen mode
("a".some, none[String], "c".some).mapN(_ ++ _ ++ _)
// None
Enter fullscreen mode Exit fullscreen mode

Or arithmetical operations:

(1.asRight, 2.asRight, 10.asRight).mapN(_ * _ * _)
// Right(20)
Enter fullscreen mode Exit fullscreen mode

And so on. Probably, the most useful is still instantiating case classes.

How to traverse things of things

The last thing weā€™ll cover is the traverse function.

You might have seen or used the traverse or sequence to go from many futures to one:

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

val futureOperations: List[Future[Int]] = List(
  Future { println("Fetch A-team"); 1 },
  Future { println("Fetch B-team"); 2 },
  Future { println("Fetch C-team"); 3 }
)

val result: Future[List[Int]] = Future.sequence(futureOperations)
Enter fullscreen mode Exit fullscreen mode

With cats, we can traverse and sequence many other things. For example, if we have a list of scores that we need to validate:

import cats.implicits._

def validateScoreE(score: Int): Either[Error, Int] =
  if (score <= 0) Left("Score must be greater than zero")
  else Right(score)

List(-1, 2, -3).traverse(validateScoreE)
// Left(Score must be greater than zero)

List(1, 2, 3).traverse(validateScoreE)
// Right(List(1, 2, 3))
Enter fullscreen mode Exit fullscreen mode

If there is one invalid score, we fail; if all the scores are valid, we successfully get a list of valid scores.

Or, if we already have a list of eithers, we can convert them into an either with a list for success:

import cats.implicits._

val input: List[Either[String, Int]] = List(1.asRight, 2.asRight, 3.asRight)

input.sequence
// Right(List(1, 2, 3))
Enter fullscreen mode Exit fullscreen mode

Imagine if you have a list of responses from different services and you want to fail if one of them fails ā€” as soon as one fails, you know that you donā€™t have to wait for the rest.

import cats.implicits._

val input: List[Either[String, Int]] =
  List(1.asRight, "Score must be greater than zero".asLeft, 2.asRight)

input.sequence
// Left(Score must be greater than zero)
Enter fullscreen mode Exit fullscreen mode

And once again, it applies to many things, not just eithers and lists ā€” when it clicks, it becomes a powerful tool.

Bonus: split a list of eithers

While weā€™re on the topic, another convenient function is separate:

import cats.implicits._

val input: List[Either[String, Int]] =
  List(1.asRight, "Score must be greater than zero".asLeft, 2.asRight)

input.separate
// (List(Score must be greater than zero),List(1, 2))
Enter fullscreen mode Exit fullscreen mode

Which separates the values into the lefts and rights.

Last note

And there are many more convenient functions and concepts ā€” a lot of ways to tease and introduce a functional library.

A reminder, just in case, try not to bring unfamiliar to you libraries to your workplace ā€” itā€™s not sustainable.


. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player