I've been struggling with error accumulation with effects, and I was wondering what the idiomatic way of modeling this is using Cats Effect.
Without involving effects, I would do something like this:
import cats._
import cats.data._
import cats.implicits._
import cats.effect._
opaque type Version = String
opaque type Path = String
case class ExampleConfig(version: Version, path: Path)
enum ValidationError:
case InvalidVersion
case InvalidPath
// Implementation irrelevant to the example
def validateVersion(version: String): ValidatedNec[ValidationError, Version] = ???
def validatePath(path: String): ValidatedNec[ValidationError, Path] = ???
def validateConfig(version: String, path: String): ValidatedNec[ValidationError, ExampleConfig] =
(validateVersion(version), validatePath(path)).mapN(ExampleConfig.apply)
However, if we now imagine validatePath
actually verifies whether the path exists on the filesystem, it becomes an effect like Sync
. This makes this case significantly more complex, as we now have:
- an additional error channel that we don't want to use, but do need to deal with
- nested types, which quickly become a hassle when mixing effectful and regular validations and when there's dependencies between them, requiring some type juggling
// Implementation irrelevant to the example
def pathExists[F[_]: Sync](path: Path): F[Boolean] = ???
def validatePath2[F[_]: Sync](path: String): F[ValidatedNec[ValidationError, Path]] =
// Such a dependency between validations leads to major painpoints,
// as we now have an effect inside a validation, while we would want it the other way around
validatePath(path)
.fold(
e => Sync[F].delay(e.invalid),
path => Sync[F].ifF(pathExists(path))(path.validNec, ValidationError.InvalidPath.invalidNec)
)
def validateConfig2[F[_]: Sync](version: String, path: String): F[ValidatedNec[ValidationError, ExampleConfig]] =
// We now have a distinction between our regular validations, and effectful validations
val versionValid: ValidatedNec[ValidationError, Version] = validateVersion(version)
for {
pathValid: ValidatedNec[ValidationError, Path] <- validatePath2(path)
// We need to deal with any errors in Syncs error channel,
// in addition to our validation error domain,
// as there technically can be additional errors in here that we want to map to validation errors
.handleError(e => ValidationError.InvalidPath.invalidNec)
} yield (versionValid, pathValid).mapN(ExampleConfig.apply)
Is there a better, idiomatic, way of modeling error accumulating effect using Cats Effect?