railway oriented programming for clojure
& clojurescript
, a functional approach to error handling.
fatum
starting as a friendly copy of fmnoise/flow, but it went in a slightly different direction to suit my personal needs.
handling exceptions in a functional way, is a non-trivial problem. one way to solve it might be the railway oriented programming (rop) principle, widely used in other functional languages, but not very often (too rarely!) found in clojure.
A program is a spell cast over a computer, turning input into error messages.
(defn create-user
"completely meaningless but well nested code"
[req]
(cond
(= 200 (:status req))
(let [user (make-user (:body req))]
(if (valid-user? user)
(let [db-repsonse (update-db :add-user user)]
(if (:success? db-reponse)
db-response
(throw (ex-info "failed updating db" {:user user :message (:message db-response)}))))
(throw (ex-info "invalid user" {:user user}))))
(= 500 (:status req))
(throw (ex-info "something went wrong"))
(= 404 (:status req))
(throw (ex-info "something went even worse"))))
sufficiently complex, repeatedly revised and refactored code tends towards an infinite number of if-else
conditions.
it is worth noting that exception handling in java is slow, very slow. using a custom type for exceptions allows the stacktrace
to be omitted. throwing also involves an additional cost, the passing of value is much cheaper and faster. creating a fail is ~50x faster than creating an exception.
the main idea behind fatum
, as behind fmnoise/flow, is to separate values from errors. exceptions are first class citizens in java/javascript
and there is no need for additional abstractions. each value can be either ok?
, either fail?
.
Fail
is a class
inheriting from ExceptionInfo
, which also behaves like a hashmap
, allowing you to instantly check the contents of ex-data
as well as add values to it. get
, assoc
, keys
, values
, seq
, all tricks allowed.
in addition, to improve performance, the stacktrace
, which is completely unnecessary in this case, is not included. creating a Fail
as opposed to throwing an Exception
is immediate and costless.
this is similar to the solution found in failjure. executes a body in try/catch
context and, if an exception is thrown, exception is turned into Fail
and returned as a value.
(f/attempt (/ 1 0))
;; => #error {
;; :cause "Divide by zero"
;; :data {}
;; :via
;; [{:type ribelo.fatum.Fail
;; :message "Divide by zero"
;; :data {}}]
;; :trace
;; []}
the most natural and idiomatic way to define a path is to use ->
, a similar solution can be found in js
, where promises are handled with an endless sequence of then
.
then
attempt to call function f
when value is not meet fail?
pred, otherwise bypass.
(-> 1
(f/then inc)
(f/then inc)
(f/then inc)
(f/then inc))
;; => 5
trying to use ->
together with try/catch
will inevitably lead to a mess. again, js
comes to the rescue, which implements a catch
for promises.
catch
attempt to call function f
when value meets fail?
pred, otherwise bypass.
(-> 1
(f/then (fn [x] (/ x 0)))
(f/catch (fn [err] (assoc err :code 418))))
;; => #error {
;; :cause "Divide by zero"
;; :data {:code 418}
;; :via
;; [{:type ribelo.fatum.Fail
;; :message "Divide by zero"
;; :data {:code 418}}]
;; :trace
;; []}
the world is not pure
and sometimes you just have to.
thru
attempt to call function f
, bypassing value unchanged
(-> 1 (f/then inc) (f/thru println))
;; => prints 2
;; => return 2
(-> 1 (f/then (fn [x] (/ x 0))) (f/thru println))
;; prints & return
;; => #error {
;; :cause "Divide by zero"
;; :data {}
;; :via
;; [{:type ribelo.fatum.Fail
;; :message "Divide by zero"
;; :data {}}]
;; :trace
;; []}
(defn create-user
"completely meaningless but well nested code"
[req]
(-> req
(f/fail-if {:staus 500} "something went wrong")
(f/fail-if {:staus 404} "something went even worse")
(f/then-if {:status 200} (comp make-user :body))
(f/fail-if (complement valid-user?) "invalid user" (partial array-map :user))
(f/then (partial update-db :add-user))
(f/fail-if (complement :success?) #(find % :message))
(f/maybe-throw)))