Error Handling

Errors in Elle are values signaled via fibers. By convention, error values are structs {:error :keyword :message "string"}, but (error val) accepts any value — integers, strings, lists. Catch handlers that assume struct shape should guard with struct? first.

Error struct convention

Error values are structs with three standard fields:

{:error   :http-error              # module/category — which subsystem failed
 :reason  :malformed-header        # specific condition — what went wrong
 :message "malformed header"}      # human-readable summary (for logs/REPL)

:error is the genus. Match on this for broad catch-all handling: "is this an HTTP problem, a DNS problem, or something else?"

:reason is the species. Match on this for targeted recovery: "was it a malformed header, an unsupported scheme, or an EOF?"

:message is prose for humans. It must never contain information that isn't already in a struct field. Programs should never need to parse the message string — every datum is in its own field.

Additional fields carry context values relevant to the specific error:

# Good: every datum is a field; message is a formatted summary
{:error :dns-format-error
 :reason :bad-rdata-length
 :rtype :a
 :expected 4
 :actual 7
 :message "A record rdata length is not 4"}

# Bad: information only in the message string
{:error :dns-error
 :message "dns: A record rdata length is not 4"}

Matching on errors

# Broad: catch all HTTP errors
(try (http:get url)
  (catch e
    (when (= e:error :http-error)
      (println "HTTP failed:" e:message))))

# Targeted: handle a specific condition
(try (irc:connect host port :nick nick)
  (catch e
    (when (= e:reason :nick-collision)
      (println "nick" e:nick "taken, trying another"))))

Raising errors

# (error val) signals an error
# (error {:error :bad-input :reason :negative-age :value age
#         :message "expected a non-negative age"})

try / catch

try runs the body; if an error occurs, the catch handler runs with the error bound to the catch variable.

(def result (try
  (/ 1 0)
  (catch e
    (string "caught: " e:message))))
result                     # => "caught: division by zero"

When no error occurs, try returns the body's value:

(try (+ 10 20) (catch e :nope))  # => 30

protect — errors as data

protect captures errors without propagating. Returns [ok? value].

(def [ok? val] (protect (+ 100 200)))
ok?                        # => true
val                        # => 300

(def [ok2? err] (protect (/ 1 0)))
ok2?                       # => false
err:error                  # => :division-by-zero

A common pattern — try something, fall back on failure:

(defn safe-parse [s]
  (def [ok? val] (protect (integer s)))
  (if ok? val nil))

(safe-parse "42")          # => 42
(safe-parse "abc")         # => nil

when-ok

Bind + branch in one step: runs body only if expr succeeds, returns nil if it errors.

(when-ok [x (+ 1 2)]
  (* x 10))               # => 30

(when-ok [x (error "oops")]
  (* x 10))               # => nil

defer — guaranteed cleanup

defer runs cleanup after body, whether body succeeds or errors.

(def log @[])
(defer (push log :cleanup)
  (push log :body)
  42)
# log is now @[:body :cleanup]
# return value is 42

On error, cleanup runs, then the error re-propagates:

(def err-log @[])
(try
  (defer (push err-log :cleanup)
    (push err-log :body)
    (error {:error :fail :reason :oops :message "oops"}))
  (catch e :caught))
# err-log is @[:body :cleanup]
# try returns :caught

with — resource management

with acquires a resource, runs body, then releases via a destructor. The destructor runs even on error.

(def rlog @[])

(defn open-conn []
  (push rlog :opened)
  {:type :conn :id 1})

(defn close-conn [c]
  (push rlog :closed))

(with conn (open-conn) close-conn
  (push rlog :used)
  conn:id)                 # => 1
# rlog is @[:opened :used :closed]

Error propagation

Errors bubble up through the call stack until caught.

(defn validate [age]
  (when (< age 0)
    (error {:error :invalid :reason :negative-age :value age
            :message "negative age"}))
  age)

(defn make-person [name age]
  {:name name :age (validate age)})

# error propagates from validate through make-person
(def err (try (make-person "Bob" -5) (catch e e)))
err:error                  # => :invalid
err:reason                 # => :negative-age
err:value                  # => -5

protect vs try/catch vs defer

              On success         On error            Use case
───────────────────────────────────────────────────────────────
try/catch     Body value         Handler result      Recovery
protect       [true value]       [false error]       Safe capture
defer         Body value         Propagates          Resource cleanup

See also