📹 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)
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)
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)
Or using cats
:
import cats.implicits._
round1 |+| round2
// Map(B-team -> 4, C-team -> 2, D-team -> 1, A-team -> 2)
Or:
import cats.implicits._
round1.combine(round2)
// Map(B-team -> 4, C-team -> 2, D-team -> 1, A-team -> 2)
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)
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)
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))
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))
// )
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)
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)
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)
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
Note that we use
Errors
instead ofError
.
We get both errors:
(validateName(""), validateScore(0)).mapN(Score.apply)
// Invalid(List(Name cannot be empty, Score must be greater than zero))
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))
Note that we use
Nec
-suffixed functions, which stand forNonEmptyChain
.
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")
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]
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))
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))
We can do other stuff, not just constructing; for example, concatenating strings:
("a".some, "b".some, "c".some).mapN(_ ++ _ ++ _)
// Some(abc)
("a".some, none[String], "c".some).mapN(_ ++ _ ++ _)
// None
Or arithmetical operations:
(1.asRight, 2.asRight, 10.asRight).mapN(_ * _ * _)
// Right(20)
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)
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))
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))
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)
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))
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.
Top comments (1)
Great content man, I've feel miss other contents about scala lang.