Chronicle
Chronicle[F, E]
allows us to both accumulate values and also short-circuit computations with a final output.
In that sense it can be seen as a hybrid of Tell
and Handle
/MonadError
.
Chronicle
is defined by 3 distinct operators, to understand it better, let's look at a simplified definition:
trait Chronicle[F[_], E] {
def dictate(c: E): F[Unit]
def confess[A](c: E): F[A]
def materialize[A](fa: F[A]): F[E Ior A]
}
If you're already familiar with Tell
, dictate
should look familiar.
In fact, it has the same signature as Tell#tell
and achieves roughly the same thing,
appending a value of E
to some internal log.
Next, we have confess
, which might seems familar if you know Raise
/ApplicativeError
's raise
/raiseError` method.
It also does the same thing here, which is raising an error that will short-circuit the computation.
This means, that internally, instances of Chronicle
need to differentiate between values of type E
.
dictate
adds a value of E
to a log, but allows the computation to continue.
Values coming from confess
on the other hand, stop all computations afterwards and short-circuit,
since calls to flatMap
will no longer have any effect.
The only way to recover from the short-circuiting behaviour of confess
is to use Chronicle
's third method materialize
.
materialize
takes an F[A]
and returns an F[E Ior A]
.
Ior
represents an inclusive-or relationship, meaning an E Ior A
can either be an E
, an A
or a tuple of both (E, A)
.
You can read more about Ior
in the cats documentation.
These three cases perfectly represent in which state the F[A]
was in.
If the F[A]
passed to materialize
was short-circuited by confess
, then the result will be only an E
.
If F[A]
at some point involved a call to dictate
, meaning it had accumulated a log of E
, then the result will be a pair of the log E
and the resulting value A
.
If neither confess
or dictate
were used to construct the F[A]
, then materialize
will only return the resulting value A
.
To gain a better understanding, let's look at an example.
Some applications call for a notion of non-fatal errors, along with the more standard fatal errors that halt computations.
Chronicle
is perfect for these kinds of use cases.
Let's say we want to create a sign up where users can register with username and password.
We can prohibit bad user data by using a computation halting confess
and nudge them in the right direction by giving warnings when e.g. their password is fairly short:
import cats.Monad
import cats.data._
import cats.syntax.all._
import cats.mtl.Chronicle
type Failures = NonEmptyChain[String]
case class Username(value: String)
case class Password(value: String)
case class User(name: Username, pw: Password)
def validateUsername[F[_]: Monad](u: String)(implicit F: Chronicle[F, Failures]): F[Username] = {
if (u.isEmpty)
F.confess(NonEmptyChain.one("Can't be empty"))
else if (u.contains("."))
F.dictate(NonEmptyChain.one("Dot in name is deprecated")).map(_ => Username(u))
else
Username(u).pure[F]
}
def validatePassword[F[_]: Monad](p: String)(implicit F: Chronicle[F, Failures]): F[Password] = {
if (p.length < 8)
F.confess(NonEmptyChain.one("Password too short"))
else if (p.length < 10)
F.dictate(NonEmptyChain.one("Password should be longer")).map(_ => Password(p))
else
Password(p).pure[F]
}
def validateUser[F[_]: Monad](name: String, password: String)(implicit F: Chronicle[F, Failures]): F[User] =
(validateUsername[F](name), validatePassword[F](password)).mapN(User)
Now we can fully validate users and accumulate a log of warnings or fail entirely when an invalid name or password was encountered.
Pretty neat, next let's actually run this program to see if what we did was correct.
We can do that with Ior
:
val luka = validateUser[Ior[Failures, *]]("Luka", "secret")
// luka: Ior[NonEmptyChainImpl.Type[String], User] = Left(
// a = Singleton(a = "Password too short")
// )
val john = validateUser[Ior[Failures, *]]("john.doe", "secret123")
// john: Ior[NonEmptyChainImpl.Type[String], User] = Both(
// a = Append(
// leftNE = Singleton(a = "Dot in name is deprecated"),
// rightNE = Singleton(a = "Password should be longer")
// ),
// b = User(
// name = Username(value = "john.doe"),
// pw = Password(value = "secret123")
// )
// )
val jane = validateUser[Ior[Failures, *]]("jane", "reallysecurepassword")
// jane: Ior[NonEmptyChainImpl.Type[String], User] = Right(
// b = User(
// name = Username(value = "jane"),
// pw = Password(value = "reallysecurepassword")
// )
// )
Apart from Ior
instances for Chronicle
also include its IorT
and any monad transformer stack where Ior
or IorT
appear.