Or how to stop throwing exceptions, discard nil and explicitly deal with your failures.
A library for explicit error handling in Clojure based on the excellent blogs "Good Enough" error handling in Clojure and Railway Oriented Programming.
The "Good Enough" blog introduces the concept of explicit error handling to Clojure, but the macro introduced sort of works like the ->> macro from the core library, but not quite. Railway Oriented Programming provides a more comprehensive explanation and demonstration of explicit error handling in function languages. This library, in the best tradition of Clojure, cherry picks the best from both articles and mixes in some real world experience to, hopefully, provide idiomatic, easy to understand and easy to use explicit error handling in Clojure.
Railway Oriented Clj aims to bring explicit error handling to Clojure in a way that is easy to understand and use. As far as possible it uses concepts already familiar to Clojure programmers and provides as few surprises as possible.
Obligatory Clojure library quote
“Negative results are just what I want. They’re just as valuable to me as positive results. I can never find the thing that does the job best until I find the ones that don’t.” ― Thomas A. Edison
For Leiningen, add the following to the dependencies key in project.clj
:dependencies
[...
[uk.co.hughpowell/railway-oriented-clj "0.3.0"]
...]
Then just require the core namespace
(ns ...
(:require ...
[uk.co.hughpowell.railway-oriented-clj.v3.core :as roc]
...))
as per usual.
By default Exceptions and nil
are treated as failures. nil
is converted to a NullPointerException at the point at which it is
encountered.
Both parameters and return values of functions are tested as to whether they are failures or not.
If you want to customise how a failure is defined you can do so using
the roc/set-...!
functions. For example, if you want to define
a failure as a map with keys :type and :message that take a keyword and
string respectively then you could do the following:
(ns my-project.core
(:require [uk.co.hughpowell.railway-oriented-clj.v3.core :as roc]))
(roc/set-failure?-fn!
(fn [value}]
(and (= (count value) 2))
(keyword? (:type value))
(string? (:message value)))))
(roc/set-exception-handler?
(fn [exception]
{:type :unexpected-excption
:message (.getMessage exception)}))
(roc/set-nil-handler?
(constantly {:type :nil
:message "nil value detected"}))
The bulk of this library consists of macros and functions for explicitly handling sequential flow control.
The threading macros, roc/->
, roc/->>
and roc/as->
,
act in a very similar way to their core counterparts, with two
exceptions.
- If a failure is passed to any of the forms or a failure is generated when the the failure is evaluated then that failure is returned immediately and no subsequent forms are evaluated.
- Only Symbols representing functions and forms that are function calls may be passed as parameters to these macros. So no macros or Java interop at the top level.
roc/when-let
is similar to the core when-let
, but allows multiple
pairs of bindings (like let
) and if one of those bindings returns a
failure evaluation is halted and the failure is returned. Again, only
forms that are function calls can be used.
roc/comp
is similar to the core version of comp
except that it will
short circuit should it encounter a failure.
This example assumes we've defined failures as we did in the last section.
(ns my-project.core
(:require [uk.co.hughpowell.railway-oriented-clj.v3.core :as roc]))
(defn read-from-database [connection data-id]
(try
...
data-from-database
(catch Exception e
{:type :database-read-error
:message (.getMessage e)})))
(defn merge-data [new-data data-from-database]
...
(if-some [merged-data ...]
merged-data
{:type :merge-failure
:message (str "Failed to merge "
input-data
" and "
data-from-database)}))
(defn save-to-database [connection merged-data]
(try
...
merged-data
(catch Exception e
{:type database-write-error
:message (.getMessage e)})))
(defn merge-new-data
[connection data-id new-data]
(roc/->> (read-from-database connection data-id)
(merge-data new-data)
(save-to-database connection)))
In this case the roc/->>
acts a lot like the core ->>
macro except if any of the forms return a failure no further forms are
evaluated and that failure is returned. So if an exception is thrown
while read-from-database
is being evaluated, an error is returned and
no other forms are evaluated.
roc/->
acts in a similar way and you could re-write merge-new-date
with roc/as->
like so
(defn merge-new-data
[connection data-id new-data]
(roc/as-> data-id $
(read-from-database connection $)
(merge-data new-data $)
(save-to-database connection$)))
What happens when we want to use the result of a function that, might
fail, in multiple subsequent forms. Normally we'd use let
(or
when-let
if we only wanted to bind one result and not do anything if
that result was nil
).
(let [result (get-result ...)]
(function-using-result result)
(other-funciton-using-result result))
But what if get-result
returns a failure. That's where roc/when-let
steps in. It can take multiple binding forms and returns a failure once
one of those forms returns a failure.
(roc/when-let [result-1 (get-result-1 ...)
result-2 (get-result-2 ...)
result-3 (get-result-3 result-1 ...)]
(do-something result-2 result-3))
If get-result-2
returns a failure get-result-3
and do-something
are never evaluated and the whole evaluation returns the failure.
Sometimes we want to evaluate multiple forms that may return a failure
but don't depend on each other. For this we have roc/combine
. This
takes a sequence of results and if they are all successes applies a
success function. Otherwise applies a failure function to the failures.
(let [result-1 (get-result-1 ...)
result-2 (get-result-2 ...)
result-3 (get-result-3 ...)]
(roc/combine identity first [result-1 result-2 result3]))
Here if all the results are successes then we return a sequence of 3 results. If there are any failures we just return the first one.
Once we actually want to deal with a failure we need roc/if-let
.
This is just like the core if-let
except that the else branch is taken
if the binding represents a failure.
(if-let [result (get-result ...)]
(return-success result)
(do
(log-error result)
(return-failure result)
There's a couple of other projects with a focus on more closely translating the functionality in Raylway Oriented Programming. One using the Clojure cats library and another that's under active development.
To download and install the library locally
git clone [email protected]:HughPowell/railway-oriented-clj.git
cd railway-oriented-clj
lein install
cd ..
To then use it in your project add it to the projects dependencies in project.clj
:dependencies
[...
[uk.co.hughpowell/railway-oriented-clj "0.3.1-SNAPSHOT"]
...]
Copyright © 2018 Hugh Powell
The contributors are listed in AUTHORS. This project uses the MPL v2 license, see LICENSE.
railway-oriented-clj uses the C4 (Collective Code Construction Contract) process for contributions.
railway-oriented-clj uses the clojure-style-guide for code style.
To report an issue, use the railway-oriented-clj issue tracker at github.com.