Friday, November 26, 2010

Non-breaking error handling in Clojure

[Edit: 28 Nov 2010] Updated the example and added another to clarify usage.

When we know that a function might (or might not) throw an exception we need to prepare for the error condition in advance. Clojure inter-operates with Java and supports catching and throwing of exceptions:

(try
  ..
  (catch FooException e
    ..))

However, in practice we often need to catch an exception and store it temporarily and probably look for few more such error conditions so that we can treat them collectively. For an example, validating user input with multiple data elements. Trying to scale try-catch to such scenarios is hard and clunky, so let's try an alternative approach - non-breaking error handling.

(defmacro maybe
  "Assuming that the body of code returns X, this macro returns [X nil] in the case of no error
  and [nil E] in event of an exception object E."
  [& body]
  `(try [(do ~@body) nil]
     (catch Exception e#
       [nil e#])))

Let's see how to use it:

;; a function that might throw IllegalArgumentException as per input
(defn valid-name
  "Check if name is valid non-empty string and return it, throw exception otherwise."
  [name]
  (let [iarg #(throw (IllegalArgumentException. "Bad name"))]
    (if (not (string? name)) (iarg))
    (let [vname (clojure.string/trim name)]
      (if (empty? vname) (iarg))
      vname)))

(defn say-hello [name]
  (let [[vname error] (maybe (valid-name name))]
    (if error (println "Error: " (.getMessage error))
      (println "Hello " vname))))

Now trying them out:

user=> (valid-name "John")
"John"

user=> (maybe (valid-name "John"))
["John" nil]

user=> (say-hello "John")
Hello  John
nil

user=> (valid-name "")
java.lang.IllegalArgumentException: Bad name (NO_SOURCE_FILE:0)

user=> (maybe (valid-name ""))
[nil #<IllegalArgumentException java.lang.IllegalArgumentException: Bad name>]

user=> (say-hello "")
Error:  Bad name
nil

So a possible error-condition got conveniently folded into a vector at a predictable index, without breaking the flow of control at the consumer end. Now let us see another example where the underlying functionality might throw an exception.

(defn safe-slurp
  "Slurp content of given filename. Return nil on error reading the file."
  [filename]
  (let [[text ex] (maybe (slurp filename))]
    (if ex nil text)))

Such a function can be used to read a configuration file and fall back on defaults if safe-slurp returns nil:

(or (safe-slurp "config-filename.properties") (system-defaults))

Summarily, I would like to note that a try-catch block forces one to think imperatively. Fortunately, there is a solution -- use 'maybe'. Please post your comments about it. You may like to follow me on Twitter.