1 line exception handling (scala.util.control.Exception) and functional programming, tie-in to zio

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

Popular posts from this blog

zio layers and framework integration

typescript and react types

typescript ambient declarations, global.d.ts, lib and typeRoot.md