Versatile error handling for every scenario.
Contingency is an experimental library for abstracting over error handling strategies. In particular, it gives developers a choice between throwing exceptions, returning errors in a variety of datatypes, and accumulating several validation-style errors. Code must be written to accomodate Contingency's generic error handling, but the changes from exception-throwing code are trivial.
- error are checked as capabilities
- choose global and localized strategies for error-handling
- fully-typesafe error handling
- selectively ignore errors considered "impossible"
- aggregate multiple errors, like a validation
- recover from specific errors with success values
- mitigate specific errors into more general errors
Contingency has not yet been published. The medium-term plan is to build Contingency with Fury and to publish it as a source build on Vent. This will enable ordinary users to write and build software which depends on Contingency.
Subsequently, Contingency will also be made available as a binary in the Maven Central repository. This will enable users of other build tools to use it.
For the overeager, curious and impatient, see building.
All Contingency terms and types are defined in the contingency
package:
import contingency.*
Contingency provides a number of different ways to work with errors in Scala, for libraries which opt in to its advanced error-handling capabilities.
Contingency builds upon Scala 3's safer exceptions with its new
boundary
/break
control flow syntax to abstract over exception handling.
We will look at how this works at the call site first, and then from the implementor's perspective.
A partial method is a method which may not produce a result for certain inputs, i.e. it is not total. Partial methods are already familiar in Scala (and Java), but the way they handle the absence of a result is invariably to throw an exception, determined directly or indirectly by the code in the method's implementation. The method's signature may also specify the types of exception it can throw, if it has been written with safer exceptions in mind.
For Contingency's purposes, a partial method is one which declares a raises
clause in its type, such as,
import fulminate.{msg, Error}
import rudiments.Bytes
case class ReadError() extends Error(msg"the data could not be read")
def readFromDisk(): Bytes raises ReadError =
Bytes() // Needs implementation
The raises ReadError
clause is equivalent to taking an additional contextual
type parameter of type Raises[ReadError]
, like so,
def readFromDisk2()(using Raises[ReadError]): Bytes =
Bytes() // Needs implementation
and this latter form is more typical for methods which may raise more than one different type of error.
Contingency introduces the terminology of raising as a generalization of throwing which is dependent on callsite context. If the callsite context calls for throwing, then raising an error will mean throwing an error. But there are alternative interpretations of raising which don't involve throwing.
Raising is modeled as a capability, represented by a Raises
value that is
implicitly passed to the readFromDisk
method. So the presence of a contextual
Raises[ReadError]
instance indicates the capability of raising a
ReadError
.
Code which calls a partial method without contextual Raises
instances of the
appropriate types to satisfy the using
parameters of the method will be a
type error. Thus, it is impossible to call a partial method which needs the
capability to raise certain error types unless those capabilities exist in the
callsite context.
This principal is critical for robust and safe error handling, since it leverages the type system to oblige the programmer to handle the exceptional cases of partial methods.
If we just don't care about error handling, for example when building a
prototype, we can effectively turn off
error handling by providing a global
contextual value which "handles" all errors by throwing them as exceptions,
import contingency.errorHandling.throwUnsafely
which is equivalent to Scala's default behavior: errors will be unchecked, and
will be thrown like traditional exceptions, bubbling through the stack until
caught in a catch
block, or reaching the bottom of the stack.
A similar, but more fine-grained use case applies if we know that (or at least make an informed judgement that) a call to a partial method will definitely return a value, and we decide that there is no point in handling an error case that will not happen in practice.
Such an expression or block of statements can be wrapped in a call to
unsafely
, which will provide a general Raises
instance necessary for
compilation, like errorHandling.throwUnsafely
, but expected to never be used,
and constrained to just that expression or those statements.
@main
def run(): Unit = unsafely:
val bytes = readFromDisk()
Out.println(bytes.length)
Similar to unsafely
is safely
, which can also wrap expressions or
statements. But unlike unsafely
, which can throw exceptions, safely
will
return an Unset
value if an error is raised. This effectively turns any
expression which would return some ReturnType
into an expression which
returns Optional[ReturnType]
.
One happy benefit of this is that the relatively expensive performance cost of
constructing and throwing an exception, only to discard it for an Unset
value, is saved. The exception instance is never constructed because Contingency
knows from the callsite context (i.e. inside the safely
wrapper) that it can
just return Unset
instead.
It is good practice for methods which do different things to raise different types of error. It makes it much easier to diagnose a problem when the error's type gives a strong indication about what went wrong.
It also encourages composition or sequencing of partial methods, confident that an error raised in the resultant expression or block will carry enough information to disambiguate it from other possible errors in the same code.
This compositionality is helpful until we reach a method boundary where we need to declare every possible error that could be raised from that method call.
Indeed, if we were defining a partial method called publish
which writes to
disk, invokes a shell command, and then sends an HTTP request, it would be
frustrating for users of that method to have to handle WriteError
s,
ShellError
s and HttpError
s when they are interested primarily in knowing
that publishing failed, and only secondarily in the underlying cause.
So rather than defining it as,
def publish(): Unit raises WriteError raises ShellError raises HttpError =
write()
invoke()
sendRequest()
we would prefer to introdue a new error type, PublishError
, containing the
detail of the issue, like so:
enum PublishIssue:
case Disk, Shell, Internet
case class PublishError(cause: PublishIssue)
extends Error(msg"publishing failed because of $cause")
def publish2(): Unit raises PublishError = ???
However, the calls to write()
, invoke()
and sendRequest()
each raise
different types of error, and the body of publish2
only has the capability of
raising PublishError
s, by virtue of its Unit raises PublishError
return
type.
The solution is to introduce mitigations.
A mitigation is a given instance which transforms an error of one type into an error of a different type. In the example above, we need mitigations to transform WriteError
s, ShellError
s and HttpError
s into PublishError
s.
We can write these as follows:
given (PublishError mitigates WriteError) = PublishError(PublishIssue.Disk).waive
given (PublishError mitigates ShellError) = PublishError(PublishIssue.Shell).waive
given (PublishError mitigates HttpError) = PublishError(PublishIssue.Internet).waive
Note that the waive
method from
Rudiments is used to transform the
value into a lambda whose variable is discarded.
Each given definition provides a new Mitigation
instance, which will be used
to construct a Raises[WriteError]
, Raises[ShellError]
or
Raises[HttpError]
as necessary, using a contextual Raises[PublishError]
.
These may be defined locally to the method, or in a more universal scope.
A full example might look like this:
def publish3(): Unit raises PublishError =
given (PublishError mitigates WriteError) = PublishError(PublishIssue.Disk).waive
given (PublishError mitigates ShellError) = PublishError(PublishIssue.Shell).waive
given (PublishError mitigates HttpError) = PublishError(PublishIssue.Internet).waive
write()
invoke()
sendRequest()
Contingency is classified as embryotic. For reference, Soundness projects are categorized into one of the following five stability levels:
- embryonic: for experimental or demonstrative purposes only, without any guarantees of longevity
- fledgling: of proven utility, seeking contributions, but liable to significant redesigns
- maturescent: major design decisions broady settled, seeking probatory adoption and refinement
- dependable: production-ready, subject to controlled ongoing maintenance and enhancement; tagged as version
1.0.0
or later - adamantine: proven, reliable and production-ready, with no further breaking changes ever anticipated
Projects at any stability level, even embryonic projects, can still be used, as long as caution is taken to avoid a mismatch between the project's stability level and the required stability and maintainability of your own project.
Contingency is designed to be small. Its entire source code currently consists of 333 lines of code.
Contingency will ultimately be built by Fury, when it is published. In the meantime, two possibilities are offered, however they are acknowledged to be fragile, inadequately tested, and unsuitable for anything more than experimentation. They are provided only for the necessity of providing some answer to the question, "how can I try Contingency?".
-
Copy the sources into your own project
Read the
fury
file in the repository root to understand Contingency's build structure, dependencies and source location; the file format should be short and quite intuitive. Copy the sources into a source directory in your own project, then repeat (recursively) for each of the dependencies.The sources are compiled against the latest nightly release of Scala 3. There should be no problem to compile the project together with all of its dependencies in a single compilation.
-
Build with Wrath
Wrath is a bootstrapping script for building Contingency and other projects in the absence of a fully-featured build tool. It is designed to read the
fury
file in the project directory, and produce a collection of JAR files which can be added to a classpath, by compiling the project and all of its dependencies, including the Scala compiler itself.Download the latest version of
wrath
, make it executable, and add it to your path, for example by copying it to/usr/local/bin/
.Clone this repository inside an empty directory, so that the build can safely make clones of repositories it depends on as peers of
contingency
. Runwrath -F
in the repository root. This will download and compile the latest version of Scala, as well as all of Contingency's dependencies.If the build was successful, the compiled JAR files can be found in the
.wrath/dist
directory.
Contributors to Contingency are welcome and encouraged. New contributors may like to look for issues marked beginner.
We suggest that all contributors read the Contributing Guide to make the process of contributing to Contingency easier.
Please do not contact project maintainers privately with questions unless there is a good reason to keep them private. While it can be tempting to repsond to such questions, private answers cannot be shared with a wider audience, and it can result in duplication of effort.
Contingency was designed and developed by Jon Pretty, and commercial support and training on all aspects of Scala 3 is available from Propensive OÜ.
Contingency (the library) provides various forms of mitagation and contingency in the event that an exception occurs at runtime.
In general, Soundness project names are always chosen with some rationale, however it is usually frivolous. Each name is chosen for more for its uniqueness and intrigue than its concision or catchiness, and there is no bias towards names with positive or "nice" meanings—since many of the libraries perform some quite unpleasant tasks.
Names should be English words, though many are obscure or archaic, and it should be noted how willingly English adopts foreign words. Names are generally of Greek or Latin origin, and have often arrived in English via a romance language.
The logo shows three tickets, each of which has been validated.
Contingency is copyright © 2024 Jon Pretty & Propensive OÜ, and is made available under the Apache 2.0 License.