Errors4s is a family of projects which attempt to provide a better base error type than java.lang.Throwable
. The foundation for which is the org.errors4s.core.Error
type.
This project provides three built in modules.
- core
- Fundamental datatypes
- cats
- Cats instances for core
- scalacheck
- Scalacheck instances for core
Add this to your libraryDependencies
in your build.sbt
.
"org.errors4s" %% "errors4s-core" % "1.0.0.0-RC3"
Error
extends java.lang.RuntimeException
thus it is a java.lang.Throwable
and can be used as a drop in replacement in any system operation on java.lang.Throwable
as the fundamental error type, however Error
provides a number of features beyond that of java.lang.Throwable
. The intent of these features is to give the developer the ability to more clearly express error states and gently push the developer to always provide useful information in the event of an application error.
In short, the additional features provided by Error
are,
- A requirement that a primary error message
String
be given and that it have a non-zero length, e.g. disallowingnew RuntimeException("")
and providing now constructor analogous tonew RuntimeException()
(with no arguments). - The ability to provide an arbitrary number of secondary error messages.
- The ability to provide an arbitrary number of causes. This is similar to
getCause
fromThrowable
, but allows handling situations when there is more than one cause. - Built in tooling aggregate primary and secondary error messages and secondary causes into a single message. This becomes the value of
getMessage
from theThrowable
type. - A number of useful default methods to create default implementations of
Error
.
For the errors4s-core
type, that's about it. The other projects in the errors4s ecosystem provide many additional utilities built on top of this small foundation.
This project was developed out of frustration of trying to deal with production errors which lacked any meaningful error message. All too often I would encounter exceptions with a null
or ""
error message. By creating an API which nudges the developer to be more descriptive about error situations, while still being easy to use, one can help reduce these situations. Overtime it also grew to be able to treat errors as immutable data with the ability to express more complex error situations. This proved to be a good foundation on which to build the other errors4s projects.
If these situations are not something which concerns you and you have no use for the more expressive API of Error
or that of the sub-projects, then there is little use for you to use this type over Throwable
.
Before we look at the API of Error
itself, we should take a brief look at the API for NonEmptyString
.
In version <= 0.1.x of this project the NonEmptyString
type from the excellent refined project was used. However in an effort to keep the dependencies of this project as small as possible and also to be able to express some more complex use cases, such as interpolation into a NonEmptyString
with compile time literals, as of version 1.0.0.0 this project provides its own org.errors4s.core.NonEmptyString
data type. This data type is primarily used for the primaryErrorMessage
of each Error
.
NonEmptyString
values can not be directly created at runtime. The only method to directly create them is from
which returns an Either[String, NonEmptyString]
, which is Left
if the given String
is null
or ""
.
import org.errors4s.core._
NonEmptyString.from("")
// res0: Either[String, NonEmptyString] = Left(Unable to create NonEmptyString from empty string value.)
NonEmptyString.from(null)
// res1: Either[String, NonEmptyString] = Left(Given String value was null. This is not permitted for NonEmptyString values.)
NonEmptyString.from("A non-empty string")
// res2: Either[String, NonEmptyString] = Right(A non-empty string)
This is a somewhat cumbersome way to create NonEmptyString
values, especially if we are using them for error messages. We don't want to always handle the Left
branch of this Either
when we are certain we are providing non-empty values.
Thankfully, NonEmptyString
provides two mechanisms to safely create instances without having to go through Either
as long as some part of the underlying String
is known at compile time to be a non-empty literal value. These mechanisms work in both Scala 2 and 3.
The first is the apply
method. This method uses a compile time macro (different ones for Scala 2 and 3) to check that the given String
is a non-empty literal value. If it is, then it lifts it into a NonEmptyString
instance, if it isn't then it yields a compilation error. For example,
NonEmptyString("A non-empty string")
// res3: NonEmptyString = A non-empty string
This works well for many situations, but sometimes we want to provide some runtime context in our NonEmptyString
. For that we can use the nes
interpolator. The nes
interpolator allows us to interpolate arbitrary values into our NonEmptyString
as long as at least some part of it is a non-empty string literal at compile time. To use this we need to import syntax.all
(or syntax.nes
). For example,
import org.errors4s.core.syntax.all._
val port: Int = 70000
// port: Int = 70000
nes"Invalid port number: ${port}"
// res4: NonEmptyString = Invalid port number: 70000
Once you have a NonEmptyString
value you can also add arbitrary other String
values to it, while retaining the NonEmptyString
. Thus an alternative way to encode the above expression could have been,
val base: NonEmptyString = NonEmptyString("Invalid port number: ")
// base: NonEmptyString = Invalid port number:
val value: NonEmptyString = base :+ port.toString
// value: NonEmptyString = Invalid port number: 70000
Error
is provided as a trait
so that it can be extended to provide the base for specialized error types, but it's companion object also provides methods to create instances of Error
directly if you do not need anything fancy.
They are all pretty straight forward, effectively allowing convenient access to all the permutations of an Error
encoding.
Error.withMessage(nes"An error has occurred")
// res5: Error = Error(primaryErrorMessage = An error has occurred, secondaryErrorMessages = Vector(), causes = Vector())
Error.withMessages(nes"An error has occurred", "It was very bad")
// res6: Error = Error(primaryErrorMessage = An error has occurred, secondaryErrorMessages = Vector(It was very bad), causes = Vector())
Error.withMessagesAndCause(nes"An error has occurred", "It was very bad", Error.withMessage(nes"This was the cause"))
// res7: Error = Error(primaryErrorMessage = An error has occurred, secondaryErrorMessages = Vector(It was very bad), causes = Vector(Error(primaryErrorMessage = This was the cause, secondaryErrorMessages = Vector(), causes = Vector())))
As mentioned above, getMessage
aggregates the entire error context together. For example,
Error.withMessagesAndCause(nes"An error has occurred", "It was very bad", Error.withMessage(nes"This was the cause")).getMessage
// res8: String = Primary Error: An error has occurred, Secondary Errors(It was very bad), Causes(Primary Error: This was the cause)
Scalacheck instances for the types in core
are provided in the scalacheck
module. If you'd like to use them in your project you can add this to your libraryDependencies
.
"org.errors4s" %% "errors4s-core-scalacheck" % "1.0.0.0-RC3"
The instances provided here are orphan instances. To use them you need to import the org.errors4s.core.scalacheck.instances._
package. You will also need to have an underlying implicit [Arbitrary][scalacheck-arbitrary] or [Cogen][scalacheck-cogen] in scope.
import org.errors4s.core._
import org.errors4s.core.scalacheck.instances._
import org.scalacheck._
val arbitraryNES: Arbitrary[NonEmptyString] = implicitly[Arbitrary[NonEmptyString]]
Instances of various Cats typeclasses for the types in core
are provided in the cats
module. If you'd like to use them in your project you can add this to your libraryDependencies
.
"org.errors4s" %% "errors4s-core-cats" % "1.0.0.0-RC3"
The instances provided here are orphan instances. To use them you need to import the org.errors4s.core.cats.instances._
package.
import cats._
import org.errors4s.core._
import org.errors4s.core.cats.instances._
val catsOrderForNonEmptyString: Order[NonEmptyString] = implicitly[Order[NonEmptyString]]
This project uses Package Versioning Policy (PVP). This is to allow long term support on a binary compatible release. (see the discussion at the end of the README)
If you need support for a version combination which is not listed here, please open an issue and we will endeavor to add support for it if possible.
Version | Cats Version | Scalacheck Version | Scala 2.11 | Scala 2.12 | Scala 2.13 | Scala 3.0 | Scala 3.1 |
---|---|---|---|---|---|---|---|
1.0.x.x | 2.x.x | 1.x.x (>= 1.15.x) | No | Yes | Yes | Yes | Yes |
This project follows Package Versioning Policy (PVP) for versioning. This is similar to Semantic Versioning (semver) with a couple small differences. The most important differences are that the first two version numbers both correspond to the major version, and thus both indicate potentially binary breaking changes, rather than just the first number as in semver. The other important difference is that there are some other circumstances which imply a major version change beyond just binary compatibility, the most common such circumstance in Scala being the deprecation of a symbol (method/function/type/class/trait/etc.).
For a more detailed overview see this writeup, as well as the official documentation for pvp.
By convention in this project (and the other errors4s projects) the first version number indicates a binary compatible dependency set and the second version number indicates internal binary compatibility. This is not strictly specified in pvp, but is compatible with it (pvp doesn't dictate when you should change the first or second version number).
The reason this project chooses to use pvp rather than the more common Early Semver is so that we can provide longer support to our users.
For example, if a dependency of an errors4s project makes a binary breaking change we can release a new version with an incremented first version number, but still keep releasing versions for the previous dependency set as well. With semver or early-semver you can only release updates to non-head branches which are non-breaking in any way, but with pvp we are free to break our internal binary API if we absolutely need to do so, even on old branches. It should be noted that this project goes to extreme lengths to not break our binary API in any case, but being able to do so means we can realistically support old branches indefinitely.