The Mechanics of Mitigation
WE SAW IN THE LAST POST how Soundness provides syntax for
capturing and recovering from errors. We can use the
mend
/within
construct to mend an error that occurs within an
expression, providing an alternative result, so execution can proceed.
This much is similar to a try
/catch
expression. But a mend
/within
expression is checked. The set of cases in the mend
block determines the
types of error that will be considered safe in the within
block. That
means any expression raising a matching error type can be evaluated freely,
and any expression raising a different type of error cannot—unless that error
type is handled elsewhere.
We will continue with the example from last time: a small application that reads some JSON data of people’s heights and weights from a file, and calculates their average BMI.
Here is the full code, with the adaptations we established in the last post:
import
soundness
.
*
type
Bmi
=
Quantity
[
Kilograms
[
1
]
&
Metres
[
-
2
]
]
case class
Person
(
name
:
Text
,
age
:
Int
,
height
:
Quantity
[
Metres
[
1
]
]
,
weight
:
Quantity
[
Kilograms
[
1
]
]
)
:
def
bmi
:
Bmi
=
weight
/
(
height
*
height
)
@
main
def
run
(
path
:
Text
)
:
Unit
=
Out
.
println
:
mend
:
case
ParseError
(
line
,
_
,
_
)
=>
t
"There was a parsing error at $
line
."
case
PathError
(
_
,
reason
)
=>
t
"There was a path error because $
reason
."
case
IoError
(
_
)
=>
t
"There was an I/O error."
.
within
:
val
json
=
Json
.
parse
(
path
.
decode
[
Path
]
.
as
[
File
]
)
val
data
=
unsafely
(
json
.
as
[
List
[
Person
]
)
val
mean
=
data
.
map
(
_
.
bmi
)
/
data
.
length
t
"The average BMI is $
mean
"
raise
Declarations
It was stated—without any proof!—that the expression
Json.parse(path.decode[Path].as[File])
could be responsible for three
types of error:
-
Json.parse
raisesParseError
-
decode
raisesPathError
, and -
Path#as
raisesIoError
How should we, as programmers, know this? And how does the compiler know that these error types need to be addressed?
The answer lies in the method signatures. And this should hardly be surprising
to any Java programmer, since that’s exactly where checked exceptions are
declared in throws
clauses.
Here is the definition of Json.parse
from the
Jacinta module of Soundness:
object
Json
:
def
parse
[
SourceType
:
Readable
by
Bytes
]
(
value
:
SourceType
)
:
Json
raises
ParseError
=
Json
(
JsonAst
.
parse
(
value
)
)
This is an interesting signature for several reasons, but the part which
interests us here is the return type, Json raises ParseError
. And it
needs to be made clear that this entire three-word phrase is a type. It is
not a return type of Json
with an additional declaration whose nature we
are yet to learn; the entire thing is a type, and can appear in any type
position in Scala code.
Json raises ParseError
and another type which appears above,
Readable by Bytes
, are fully-applied infix types. They are identical to
the types raises[Json, ParseError]
and by[Readable, Bytes]
, formed from
type constructors raises
and by
. But we are permitted by the compiler
to write them in infix style.
Principal Types and Supplements
This provides welcome fluidity to our code, and means we can write a return
type like Json raises ParseError
and have it first express the most important
detail of the return type, that the result will be an instance of Json
, but
furthermore, that ParseError
is raised.
I’m not aware of any established terminology for the concept, so I shall call Json
the principal type of Json raises ParseError
and raises ParseError
is a
supplement to the type. The supplement is not a type itself, but could be
interpreted as a type constructor, since supplementing a type T
with
raises ParseError
transforms the proper type T
into a new proper type,
T raises ParseError
.
I will use this terminology exclusively for infix type constructors. The purpose of the terms principal and supplement is to describe the syntactic role played by each part of the full type. These are not new types of type in Scala’s type sysetm. They are just a means of labelling existing concepts for better understanding.
It is nevertheless the supplement that tells the compiler that calling
Json.parse
raises ParseError
s, which must be handled. And equally, it is
this supplement which tells the programmer, you or I, that if we invoke
Json.parse
, we must also write
code to ensure that ParseError
is handled, one way or another.
Implementation
Let’s explore the underlying mechanism that makes this possible.
Tactic
s and Raising Errors
Let’s revisit the definition of Json.parse
. We can write that definition
in a different way, in terms of a contextual Tactic
instance for handling
ParseError
s.
Select ❶ or ❷ below to see these alternative, but equivalent, ways of
defining parse
:
object
Json
:
def
parse
[
SourceType
:
Readable
by
Bytes
]
(
value
:
SourceType
)
(
using
Tactic
[
ParseError
]
)
:
Json
raises
ParseError
=
Json
(
JsonAst
.
parse
(
value
)
)
A Tactic
can be thought of as a localized strategy for handling a particular
type of error, passed into the method from the call-site. This delegates the
choice of how each type of error will be handled to the outside world, and
it means we must use Tactic
’s simple abstract interface within the method body to
indicate when an error occurs.
(In the example of Json.parse
, the implementation is not so so interesting:
it just delegates to a lower-level method, which means passing the Tactic
instance implicitly to the method it calls, so we do not see this
interface—yet.)
Either signature for parse
works equally well. Thanks to the definition of
raises
, they are equivalent. But it will take several steps
to show it. Here’s the definition of raises
:
infix
type
raises
[
SuccessType
,
ErrorType
<:
Exception
]
=
Tactic
[
ErrorType
]
?=>
SuccessType
This is a type alias, so any appearance of A raises B
can
be substituted for Tactic[B] ?=> A
without changing the semantics.
Context Functions
The ?=>
indicates a context function type. Context Functions are a very
powerful feature introduced in Scala 3. And while they are more apparent on
the definition-side of error handling, they are absolutely foundational
throughout.
Any Scala programmer should be familiar with functions. They are objects which can be invoked, taking values as input, and producing a value as output. Unlike methods which are members of objects, functions are representations of methods, but which are themselves values. Scala frequently and seamlessly converts methods into function values, to the extent that we often don’t notice it happening.
The transformation between a method definition and its function-value equivalent can be seen in this example:
case class
Year
(
value
:
Int
)
def
val
leapYear
(
year
:
Year
)
:
=>
Boolean
=
year
=>
val
n
=
year
.
value
n
%
400
==
0
||
n
%
4
==
0
&&
n
%
100
!=
0
The implementation of leapYear
is indeed different, but
any invocation of leapYear(y)
for a year, y
, will behave the same. In
almost every case we prefer a method definition (with def
) for clarity,
and (to a lesser extent) performance.
But a method’s parameters may also be contextual, as indicated by the
using
keyword. In Scala 2, these
were called implicit
parameters. There is an equivalent functional value
representation of a method taking a contextual parameter, using the ?=>
operator:
case class
Year
(
value
:
Int
)
def
val
leapYear
(
using
:
Year
)
:
?=>
Boolean
=
val
n
=
summon
[
Year
]
.
value
n
%
400
==
0
||
n
%
4
==
0
&&
n
%
100
!=
0
Given either variation of the definition, leapYear
is an expression which
will evaluate to either true
or false
, provided there is a given instance
of Year
in scope. For example,
given
Year
(
1984
)
Out
.
println
(
t
"It $
{
if
leapYear
then
t
"is"
else
t
"isn't a"
}
leap year."
)
These two transformations from methods to functional values are similar, but
there was one difference which might not have been obvious at first glance:
When we converted to the context-functional form of leapYear
, we did not
change the right-hand side implementation at all, whereas when we converted
the first variant, we had to introduce year =>
to the right-hand side
because its type had changed into a lambda (and we would have had no
identifier for referring to lambda variable, otherwise).
Both methods are virtually the same in Java bytecode. Both functional values are virtually the same in Java bytecode. And the transformations between them are mirrors of each other. And the right-hand side of both is concretely a lambda.
But we are allowed to elide the explicit lambda variable (year =>
) for the
context function because it can be inferred from the return type.
And this is true in general: any expression or block of code whose expected
type is A ?=> B
will be implemented as a lambda, but requires no explicit
lambda variable to be specified. However, crucially, that expression
or block can be written as if an instance A
is given in its context;
silently injected into the scope.
It is as if we had written an additional
given
definition at the start of the block, like this:
val
leapYear
:
Year
?=>
Boolean
=
y
=>
given
Year
=
y
val
n
=
summon
[
Year
]
.
value
n
%
400
==
0
||
n
%
4
==
0
&&
n
%
100
!=
0
It is safe precisely because the type of the expression, as a context
function, guarantees it: an instance of A ?=> B
may only be used in a scope
only if a contextual instance of A
is present—just as we can only compute
the result of a function A => B
by passing it an instance of A
which we
have. The
difference with context functions is that they may be composed like
functions, but need no explicit references to their parameters at the
term-level.
With this knowledge, we can show the equivalence of the two implementations
of Json.parse
from earlier:
-
The return type
Json raises ParseError
is syntactic sugar forraises[Json, ParseError]
-
raises[Json, ParseError]
dealiases toTactic[ParseError] ?=> Json
-
Tactic[ParseError] ?=> Json
is equivalent to a return type ofJson
and an additionalusing
parameter of typeTactic[ParseError]
Effective Error-handling Tactic
s
It is contextual Tactic
instances, like Tactic[ParseError]
and
Tactic[IoError]
which confer both the need and the capability of raising
a certain error type.
Supplementing a method’s return type with raises IoError
confers a given contextual Tactic[IoError]
into the body of the method,
while at the same time requiring that the method may only be invoked in a
context where there is a given Tactic[IoError]
.
It is context functions which make it possible to define mend
/within
.
Although macros are required to implement mend
, the parameter to within
,
wherein the happy evaluation path is expressed, is a context function which
provides certain Tactic
s. It is the macro which infers those Tactic
s—one
for each case in the mend
block.
To make this clearer, let’s show how this mechanism works with a specific
mend
/within
example. We can avoid thinking about the full complexity of
the macro by considering just one particular expansion of it for a fixed set
of Tactic
s.
mend
:
case
ParseError
(
line
,
_
,
_
)
=>
Json
(
t
"Parse error"
)
case
PathError
(
_
,
reason
)
=>
Json
(
t
"Path error"
)
case
IoError
(
_
)
=>
Json
(
t
"I/O error"
)
.
within
(
Json
.
parse
(
path
.
decode
[
Path
]
.
as
[
File
]
)
)
Different mend
blocks will infer different within
definitions, but for this
particular example, the signature of within
will be the following:
def
within
(
body
:
(
Tactic
[
ParseError
]
,
Tactic
[
PathError
]
,
Tactic
[
IoError
]
)
?=>
Json
)
:
Json
Thus, within
is a method taking a single parameter. That parameter is a context function taking
three input values, Tactic[ParseError]
, Tactic[PathError]
and
Tactic[IoError]
, with a return type of Json
. And Json
is also the return type of
within
, representing the successful computation of its body
parameter.
At the call-site, we can invoke within
as if instances of each of these
three Tactic
s are present. So we are permitted to call Json.parse
because,
and only because, the type of the body
parameter infers their presence.
More generally, every error type irrefutably matched by the cases in the mend
block will produce a corresponding Tactic
parameter to the body
context
function. And every Tactic
parameter will provide a given
instance to the
within
expression.
The implementation of our particular within
definition is not shown,
but it’s worth considering
parametrically. Its implementation has an instance of body
, an instance of a
context function passed in from the call-site. Invoking the context function is
its only means of constructing the Json
instance, which is required for its
return value.
The body
context function can be invoked with three Tactic
instances, but
we do not have these so they
must be constructed. Their implementations correspond to the three cases
in the mend
block, and this work is handled by the macro.
Breaking boundaries
One key component of the implementation is the boundary
/break
syntax
which was introduced in Scala 3.3.0. The
macro invokes body
inside a delimited boundary
, and each of the Tactic
instances is defined to break
directly to that boundary, invoking
the right-hand side
of its corresponding case
clause to get an alternative Json
value.
Thankfully, in usage we rarely need to consider any of this. From a usage
perspective, we simply declare the errors we wish to handle in mend
, and we
are then liberated to invoke any expressions which raise those errors inside
within
.
But it is worthwhile remembering that contextual Tactic
s are the mechanism
that expresses the
need and the capacity to raise each error type.
Raising Multiple Error Types
In the interests of reusability, we might like to split the run
method
of our example into several methods.
Let’s rewind to our initial prototype version, without any error-handling code, to see the transformation we would like to make:
import
strategies
.
uncheckedErrors
def
readJson
(
path
:
Text
)
:
Json
=
Json
.
parse
(
path
.
decode
[
path
]
.
as
[
File
]
)
@
main
def
run
(
path
:
Text
)
:
Unit
=
val
json
=
Json
.
parse
readJson
(
path
.
decode
[
Path
]
.
as
[
File
]
)
val
data
=
json
.
as
[
List
[
Person
]
]
val
mean
=
data
.
map
(
_
.
bmi
)
/
data
.
length
Out
.
println
(
t
"The average BMI is $
mean
"
)
Hopefully the extraction of the readJson
method looks like a natural
refactoring, in the interests of reusability.
But how much harder is this with error-handling in place? Rather than having a simple answer, this introduces a new question: which method should handle the errors? And this is a question of good design.
We can leave the error-handling code in the run
method, but in order
to compile the body of readJson
, its signature needs to declare the
three error types in raises, like so:
def
readJson
(
path
:
Text
)
:
Json
raises
ParseError
raises
PathError
raises
IoError
=
Json
.
parse
(
path
.
decode
[
Path
]
.
as
[
File
]
)
@
main
def
run
(
path
:
Text
)
:
Unit
=
Out
.
println
:
mend
:
case
ParseError
(
line
,
_
,
_
)
=>
t
"There was a parsing error at $
line
."
case
PathError
(
_
,
reason
)
=>
t
"There was a path error because $
reason
."
case
IoError
(
_
)
=>
t
"There was an I/O error."
.
within
:
val
json
=
readJson
(
path
)
val
data
=
unsafely
(
json
.
as
[
List
[
Person
]
)
val
mean
=
data
.
map
(
_
.
bmi
)
/
data
.
length
t
"The average BMI is $
mean
"
The return type, Json raises ParseError raises PathError raises IoError
communicates the three error types raised by the method, but it is hardly
succinct. A hypothetical future version of Scala might introduce syntax
that would permit us to write,
Json raises {ParseError, PathError, IoError}
, but at the time of writing,
that exists only as fantasy.
We might be content to accept this verbosity as one of our lesser problems. Though unfortunately it only gets worse. We value the ability to compose several simple expressions in a larger expression, and to hide its complexity behind a method definition, so it might appear as a simple expression that can be composed into a larger expression... and so on. The simplicity of this composition is egregiously undermined if the type signature of each method must grow with each composition, to include supplements for every error type raised for the expanded tree of computations.
This simply does not scale.
An alternative (but not the only one) is to transform errors of one type into errors of another type. If we transform several different error types into the same error type (or even fewer error types), then we can simplify the type-explosion problem for each method definition, and ensure that the supplement to each type is of a manageable size.
For our example, ParseError
, PathError
and IoError
are all problems with
reading. We could introduce a new error type, ReadError
to represent all of
these possible problems.
Let’s define the simplest parameterless ReadError
we can, as a subtype of Soundness’s
Error
type.
We can then use a new construct, tend
to define the transformations. In code,
tend
looks very similar to mend
. It even rhymes! But whereas the
right-hand side of each mend
case was an alternative result value, the
right-hand side of each tend
case is an error value to be raised.
If an error arises during the execution of a tend
’s within
block, then
unlike mend
, failure is still guaranteed. But the nature of the error is
determined by the cases in the tend
block.
Here is the full example. Hover over the highlighted parts of the code for more detail:
def
readJson
(
path
:
Text
)
:
Json
raises
ReadError
=
tend
:
case
ParseError
(
_
,
_
,
_
)
=>
ReadError
(
)
case
PathError
(
_
,
_
)
=>
ReadError
(
)
case
IoError
(
_
)
=>
ReadError
(
)
.
within
(
Json
.
parse
(
path
.
decode
[
Path
]
.
as
[
File
]
)
)
@
main
def
run
(
path
:
Text
)
:
Unit
=
Out
.
println
:
mend
:
case
ReadError
(
)
=>
t
"There was a problem reading the file"
.
within
:
val
json
=
readJson
(
path
)
val
data
=
unsafely
(
json
.
as
[
List
[
Person
]
)
val
mean
=
data
.
map
(
_
.
bmi
)
/
data
.
length
t
"The average BMI is $
mean
"
We are now using mend
, tend
and unsafely
, and our error handling code is
spread across both methods. There is no issue here. All three methods, as well
as safely
, complement each other. They can be nested. They compose.
But it remains clean and clear, and we can reason about it in terms of
Tactic
s:
-
inside the first
within
block there are contextualTactic[ParseError]
,Tactic[PathError]
andTactic[IoError]
instances -
the
tend
/within
construct requires a contextualTactic[ReadError]
because other error types inside are transformed intoReadError
s, which must be handled outside -
readJson
’s return type isJson raises ReadError
, which means aTactic[ReadError]
is available in its body -
the invocation of
readJson(path)
therefore requires aTactic[ReadError]
-
the
mend
block handlesReadError
, and therefore provides aTactic[ReadError]
to itswithin
block -
unsafely
provides an arbitraryTactic
for anyError
type
So although the implementations of mend
and tend
are complex macros, which
I am deliberately avoiding, all are built upon Scala’s long-established system
of scoped contextual values—formerly known as implicits but now known as
givens.
This means that not only can we reason about working code; we can get useful
feedback on
code which does not compile. If we omitted one case from a tend
block, it
would cause a compile error, which would specify precisely the type of error
we have failed to handle. Likewise, if we forgot the supplement
raises ReadError
in the definition of readJson
, it’s a compile error—but
we are told precisely which error type we haven’t handled.
The benefits of this static constraint of correctness should not be underestimated. Next time, I’ll elaborate some more on the confidence it gives us, as developers, and I’ll start to explore how we should design errors to maximize our advantage.
❦