1 line exception handling (scala.util.control.Exception) and functional programming, tie-in to zio
I’ve been using scala3/dotty for awhile and am in the process of converting around 25 of my libraries to the new syntax and compiler. That’s another story.
As I am converting code in my own libraries, I remember transcribing a small number of zio-sql
functions and types into dotty/scala3 to see if I would like the optional brace syntax. For that test, I used different exception handling constructs than just try-catch
. I was thinking about zio-sql today because of some activity related to that project. It prompted me to write up this thought since I had not blogged in awhile.
The scala.util.control.Exception
class has been around for awhile but may not be widely used. Typically when you encounter an error, you throw an exception. If you are not using a functional programming (FP) style you are most likely letting the exception propagate up, up, and up! In contrast, the functional programming style often treats errors as values and code closer to the error handles the error.
Here’s that zio-sql
test code. Since I translated it awhile ago to optional braces as a test, it may not represent the latest code on github. It uses optional braces syntax and colon indents:
import ...
import scala.util.control.Exception._
enum DecodingError:
case UnexpectedNull(column: Int|String)
case UnexpectedType(expected: String, actual: Int)
case MissingColumn(column: Int|String)
case Closed
def message = this match
case UnexpectedNull(column) =>
val label = column.toString
s"Expected column $label to be non-null"
case UnexpectedType(expected, actual) =>
s"Expected type $expected but found $actual"
case MissingColumn(column) =>
val label = column.toString
s"The column $label does not exist"
case Closed => s"The ResultSet has been closed, so decoding is impossible"
trait Decoder[+A]:
def decode(column: String|Int, noNull: Boolean)(resultSet: ResultSet): DecodingError | A
def tryDecode[A](
column: String|Int,
fromIntIndex: Int => A,
fromStringIndex: String => A,
nonNull: Boolean,
resultSet: ResultSet,
validateColumn: Boolean = true
): DecodingError | A =
val md = resultSet.getMetaData()
if resultSet.isClosed() then DecodingError.Closed
else
val columnExists =
column match
case i: Int => i >= 1 && i <= md.getColumnCount()
case s: String => failAsValue(classOf[SQLException])(false) apply { resultSet.findColumn(s); true }
if !columnExists && validateColumn then DecodingError.MissingColumn(column)
else
failAsValue(classOf[SQLException])(DecodingError.UnexpectedType("unexpected type", 0)):
val value = column match
case i: Int => fromIntIndex(i)
case s: String => fromStringIndex(s)
if value == null && nonNull then DecodingError.UnexpectedNull(column)
else value
This is not a prefect rendering of the code in scala3 as I was just fiddling around back then.
The thing to note is failAsValue
.
case s: String => failAsValue(classOf[SQLException])(false) apply { resultSet.findColumn(s); true }
This is an scala.util.control.Exception
function that catches the exception of the specified type then returns a value in its place. The apply
applies the “catch” to the code, in this case findColumn
. findColumn
is from JDBC and throws a SQLException
if the column is not found. So instead of a large try-catch
, it’s a one-liner.
In the 2nd use of failAsValue
, the colon syntax is used so you do not need apply { ... }
with braces.
failAsValue(classOf[SQLException])(DecodingError.UnexpectedType("unexpected type", 0)):
val value = column match
...
Semantically, you list the error handling prior to the computation. That initially felt wrong, but I realized that I usually always check the docs first anyway to see how the function could fail first anyway, so it is something I became used to.
I think try-catch
is finger-reflex-friendly and the familiar try-catch
would be easier for new programmers to spot. But as I use failAsValue
more, it has become part of my lexicon.
If I had used try-catch
it would have stretched out over a few lines although nothing excessive in this case. With optional braces, it would look clean, but it is still more lines of code (vertical spanning) versus calling a function like above. It is obvious that you could code your own failAsValue
to get rid of try-catch
blocks, but failAsValue
is already available and there are alot of other useful combinators in scala.util.control.Exception
.
When I use a more functional style where errors are values, I shrink my exception handling to be more concise using the scala.util.control.Exception
functions. I’ll admit that using the Exception
DSL is still not natural to me but its growing on me quickly.
You may also note that for zio there are many combinators that are like scala.util.control.Exception
's functions. That’s no coincidence. When working functionally, you need ergonomic methods for error handling. I have used zio on a few projects now very successfully and happily. There are many zio combinators for scala standard types that can represent failure, e.g., Option, Either, and Try. In zio, error handling in much more explicit than with normal exception-throwing code. That’s why zio has those combinators there and that’s why scala.util.control.Exception
's methods feel much more natural now.
Oh, and check out the zio-sql
projects as it is moving along nicely: https://github.com/zio/zio-sql.
It is the way.
That’s it!
Comments
Post a Comment