Solutions to Scala with Cats: Chapter 10
April 8, 2023These are my solutions to the exercises of chapter 10 of Scala with Cats.
Table of Contents
- Exercise 10.3: Basic Combinators
- Exercise 10.4.2: Checks
- Exercise 10.4.3: Recap
- Exercise 10.5: Kleislis
Exercise 10.3: Basic Combinators
The and
method of Check
will create a new Check
that calls apply
on both
instances. However, we soon hit the problem of what to do if they both return a
Left
:
def and(that: Check[E, A]): Check[E, A] =
new Check[E, A] {
def apply(value: A): Either[E, A] = {
val selfCheck = self.apply(value)
val thatCheck = that.apply(value)
// How to combine if both fail?
???
}
}
We need a way to combine values of type E
, which hints towards the need for a
Semigroup
instance for E
. We’re assuming that we don’t want to short-circuit
but rather accumulate all errors.
For the and
implementation, we follow the algebraic data type style that is
recommended by the book:
import cats.Semigroup
import cats.syntax.either._
import cats.syntax.semigroup._
sealed trait Check[E, A] {
import Check._
def and(that: Check[E, A]): Check[E, A] =
And(this, that)
def apply(a: A)(implicit s: Semigroup[E]): Either[E, A] =
this match {
case Pure(func) =>
func(a)
case And(left, right) =>
(left(a), right(a)) match {
case (Left(e1), Left(e2)) => (e1 |+| e2).asLeft
case (Left(e), Right(_)) => e.asLeft
case (Right(_), Left(e)) => e.asLeft
case (Right(_), Right(_)) => a.asRight
}
}
}
object Check {
final case class And[E, A](left: Check[E, A], right: Check[E, A]) extends Check[E, A]
final case class Pure[E, A](func: A => Either[E, A]) extends Check[E, A]
def pure[E, A](f: A => Either[E, A]): Check[E, A] =
Pure(f)
}
Validated
is a more appropriate data type to accumulate errors than Either
.
We can also rely on the Applicative
instance for Validated
to avoid the
pattern match:
import cats.Semigroup
import cats.data.Validated
import cats.syntax.apply._
sealed trait Check[E, A] {
import Check._
def and(that: Check[E, A]): Check[E, A] =
And(this, that)
def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
this match {
case Pure(func) =>
func(a)
case And(left, right) =>
(left(a), right(a)).mapN((_, _) => a)
}
}
object Check {
final case class And[E, A](left: Check[E, A], right: Check[E, A]) extends Check[E, A]
final case class Pure[E, A](func: A => Validated[E, A]) extends Check[E, A]
def pure[E, A](f: A => Validated[E, A]): Check[E, A] =
Pure(f)
}
The or
combinator should return a Valid
if the left hand side is Valid
or
if the left hand side is Invalid
but the right hand side is Valid
. If both
are Invalid
, it should return an Invalid
combining both errors. Due to the
latter, we can’t rely on orElse
but rather have a slightly more complicated
implementation:
import cats.Semigroup
import cats.data.Validated
import cats.syntax.apply._
import cats.syntax.semigroup._
sealed trait Check[E, A] {
import Check._
def and(that: Check[E, A]): Check[E, A] =
And(this, that)
def or(that: Check[E, A]): Check[E, A] =
Or(this, that)
def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
this match {
case Pure(func) =>
func(a)
case And(left, right) =>
(left(a), right(a)).mapN((_, _) => a)
case Or(left, right) =>
left(a) match {
case Validated.Valid(a) => Validated.Valid(a)
case Validated.Invalid(el) =>
right(a) match {
case Validated.Valid(a) => Validated.Valid(a)
case Validated.Invalid(er) => Validated.Invalid(el |+| er)
}
}
}
}
object Check {
final case class And[E, A](left: Check[E, A], right: Check[E, A]) extends Check[E, A]
final case class Or[E, A](left: Check[E, A], right: Check[E, A]) extends Check[E, A]
final case class Pure[E, A](func: A => Validated[E, A]) extends Check[E, A]
def pure[E, A](f: A => Validated[E, A]): Check[E, A] =
Pure(f)
}
Exercise 10.4.2: Checks
With our previous Check
renamed to Predicate
, we can implement the new
Check
with the proposed interface as follows, using an algebraic data type
approach as before:
import cats.Semigroup
import cats.data.Validated
sealed trait Check[E, A, B] {
import Check._
def apply(a: A)(implicit s: Semigroup[E]): Validated[E, B]
def map[C](func: B => C): Check[E, A, C] =
Map[E, A, B, C](this, func)
}
object Check {
final case class Map[E, A, B, C](check: Check[E, A, B], func: B => C) extends Check[E, A, C] {
def apply(a: A)(implicit s: Semigroup[E]): Validated[E, C] =
check(a).map(func)
}
final case class Pure[E, A](pred: Predicate[E, A]) extends Check[E, A, A] {
def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
pred(a)
}
def pure[E, A](pred: Predicate[E, A]): Check[E, A, A] =
Pure(pred)
}
flatMap
is a bit weird to implement because we don’t have a flatMap
for
Validated
. Fortunately, we have flatMap
in Either
and a withEither
method in Validated
that allows us to apply a function over an Either
that
gets converted back to a Validated
.
sealed trait Check[E, A, B] {
// ...
def flatMap[C](func: B => Check[E, A, C]) =
FlatMap[E, A, B, C](this, func)
// ...
}
object Check {
// ...
final case class FlatMap[E, A, B, C](check: Check[E, A, B], func: B => Check[E, A, C])
extends Check[E, A, C] {
def apply(a: A)(implicit s: Semigroup[E]): Validated[E, C] =
check(a).withEither(_.flatMap(b => func(b)(a).toEither))
}
// ...
}
andThen
gets implemented very similarly to flatMap
, except that we don’t use
the output of the first Check
to decide which other Check
to use. The next
Check
is already statically provided to us:
sealed trait Check[E, A, B] {
// ...
def andThen[C](that: Check[E, B, C]): Check[E, A, C] =
AndThen[E, A, B, C](this, that)
// ...
}
object Check {
// ...
final case class AndThen[E, A, B, C](left: Check[E, A, B], right: Check[E, B, C])
extends Check[E, A, C] {
def apply(a: A)(implicit s: Semigroup[E]): Validated[E, C] =
left(a).withEither(_.flatMap(b => right(b).toEither))
}
// ...
}
Exercise 10.4.3: Recap
The helper predicates that are introduced in this exercise make use of a lift
method on Predicate
that we haven’t implemented yet. Its implementation can be
something like the following:
object Predicate {
// ...
def lift[E, A](e: E, func: A => Boolean): Predicate[E, A] =
pure(a => if (func(a)) Validated.Valid(a) else Validated.Invalid(e))
// ...
}
A Check
for username can be implemented as follows, making use of the
longerThan
and alphanumeric
predicates.
val usernameCheck = Check.pure(longerThan(3) and alphanumeric)
A Check
for the email address can be implemented as follows. We first check
that the string contains at least one @
, then split the string, check each of
the sides and combine them back at the end:
val emailAddressCheck = {
val checkLeft =
Check.pure(longerThan(0))
val checkRight =
Check.pure(longerThan(3) and contains('.'))
val checkLeftAndRight =
Check.pure(Predicate.pure[Errors, (String, String)] { case ((left, right)) =>
(checkLeft(left), checkRight(right)).mapN((_, _))
})
Check
.pure(containsOnce('@'))
.map({ str =>
val Array(left, right) = str.split("@")
(left, right)
})
.andThen(checkLeftAndRight)
.map({ case ((left, right)) => s"$left@$right" })
}
Exercise 10.5: Kleislis
The run
method on Predicate
must return a A => Either[E, A]
. We must rely
on the existing apply
method so we also need a Semigroup
instance for E
:
sealed trait Predicate[E, A] {
// ...
def run(implicit s: Semigroup[E]): A => Either[E, A] =
a => apply(a).toEither
// ...
}
Our checks don’t change much. We have decided to implement the email address check slightly differently here, applying the checks directly in the split step:
val usernameCheck = checkPred(longerThan(3) and alphanumeric)
val emailAddressCheck = {
val checkLeft: Check[String, String] =
checkPred(longerThan(0))
val checkRight: Check[String, String] =
checkPred(longerThan(3) and contains('.'))
val split: Check[String, (String, String)] =
check(_.split('@') match {
case Array(name, domain) =>
Right((name, domain))
case _ =>
Left(error("Must contain a single @ character"))
})
val join: Check[(String, String), String] =
check({ case (l, r) => (checkLeft(l), checkRight(r)).mapN(_ + "@" + _) })
split andThen join
}