Skip to main content
Version: 2.x

Model Domain Errors Using Algebraic Data Types

It is best to use algebraic data types (ADTs) when modeling errors within the same domain or subdomain.

Sealed traits allow us to introduce an error type as a common supertype and all errors within a domain are part of that error type by extending that:

sealed trait UserServiceError extends Exception

case class InvalidUserId(id: ID) extends UserServiceError
case class ExpiredAuth(id: ID) extends UserServiceError

In this case, the super error type is UserServiceError. We sealed that trait, and we extend it by two cases, InvalidUserId and ExpiredAuth. Because it is sealed, if we have a reference to a UserServiceError we can match against it and the Scala compiler knows there are two possibilities for a UserServiceError:

userServiceError match {
case InvalidUserId(id) => ???
case ExpiredAuth(id) => ???
}

This is a sum type, and also an enumeration. The Scala compiler knows only two of these UserServiceError exist. If we don't match on all of them, it is going to warn us. We can add the -Xfatal-warnings compiler option which treats warnings as errors. By turning on the fatal warning, we will have type-safety control on expected errors. So sealing these traits gives us great power.

Also extending all of our errors from a common supertype helps the ZIO's combinators like flatMap to auto widen to the most specific error type.

Let's say we have this for-comprehension here that calls the userAuth function, and it can fail with ExpiredAuth, and then we call userProfile that fails with InvalidUserID, and then we call generateEmail that can't fail at all, and finally we call sendEmail which can fail with EmailDeliveryError. We have got a lot of different errors here:

val myApp: IO[Exception, Receipt] =
for {
service <- userAuth(token) // IO[ExpiredAuth, UserService]
profile <- service.userProfile(userId) // IO[InvalidUserId, Profile]
body <- generateEmail(orderDetails) // IO[Nothing, String]
receipt <- sendEmail("Your order detail",
body, profile.email) // IO[EmailDeliveryError, Unit]
} yield receipt

In this example, the flatMap operations auto widens the error type to the most specific error type possible. As a result, the inferred error type of this for-comprehension will be Exception which gives us the best information we could hope to get out of this. We have lost information about the particulars of this. We no longer know which of these error types it is. We know it is some type of Exception which is more information than nothing.