~ / track D / clojure in practice

Error handling: exceptions vs values

Intermediatepractice

Clojure's error story is two-layered. At the language level you get exceptions: throw, try, catch, finally. At the design level the question becomes when to use them, and the answer most experienced Clojure code converges on is: exceptions for the unexpected, return values for the expected. This page is about how to draw that line, and the small handful of patterns that fall out of it. For multi-step pipelines see Railway-oriented programming; Malli schemas return validation errors at the HTTP boundary; and Category theory with cats wraps the same ideas in monadic vocabulary when you want a library.

Exceptions you can read

Clojure inherits the host's exception type and adds ex-info — an exception that carries a map of structured data:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

ex-info is the difference between an error message and an error you can program against. The catching code reads :type and :reason from the data map and decides what to do; the message stays human.

Two habits keep ex-info useful:

  • Always include a :type (or :kind) key. It's what your catch arm dispatches on. Without it you're regex-matching the message, which is fragile.
  • Don't put exceptions in the data map. Stash a string :cause-msg if you need it, but the data should be plain edn so logs and dashboards can read it.

When to throw vs return

The dividing line:

  • Throw when the caller can't reasonably continue: invariant violated, programmer mistake, contract broken, infrastructure failure.
  • Return a tagged result when failure is part of the function's interface — when a sensible caller will branch on success and failure.

parse-int is a good example. Failure isn't exceptional; users type garbage all the time. The function should return nil (or a tagged failure), not throw. Conversely, (/ x 0) blowing up is correct — division-by-zero is a bug in the caller, not a value the program should plan to receive.

A function whose docstring says "throws on bad input" probably shouldn't.

Patterns for "expected failure"

Three common shapes, in increasing structure:

Return nil for the empty result.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Simple, idiomatic, lets w‌h‌e‌n‌-‌l‌e‌t/i‌f‌-‌l‌e‌t work naturally. The cost: you lose the reason for the failure.

Return a tagged tuple [:ok v] / [:error info].

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

The caller c‌a‌s‌es on the first element. This is the Clojure-idiomatic flavor of an Either — no library required, plain data, the printer shows the whole thing, and you can destructure it with l‌e‌t.

Return a result map {:status … :value …}.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

A map is the most flexible shape — you can add :warnings, :trace, :elapsed-ms without breaking callers. It's the right choice when there are more than two outcomes.

Threading through failure (a.k.a. railway)

When a request walks several "could-fail" steps, the cascading i‌f‌-‌l‌e‌t gets tedious. A small helper short-circuits on the first failure:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

This is the kernel of "railway-oriented programming" — the success arm flows down, the error arm short-circuits. Libraries like cats or failjure wrap this in a monad-y vocabulary; for most applications the hand-rolled version is enough.

Errors as data from a matcher

The same idea applies to validation. A matcher that gates a step can return nil on a miss — but nil loses the reason. Return an error value instead and the failure stays as data you can thread, log, and convert to a status:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

A small when-error-throw utility ((if (:error x) (throw (ex-info ...)) x)) re-raises at the edge so the happy path keeps threading, and a match-or variant that takes an unmatch-handler lets each caller decide whether a miss becomes nil, an error map, or an exception — abstraction hiding the branching so the controller stays a flat pipeline.

try/catch/finally

The mechanics, kept short:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

finally runs no matter what; use it to close files, release locks, unsubscribe watches. with-open (for things implementing Closeable) is the higher-level form and you should prefer it.

catch :default is the catch-all in ClojureScript (and Babashka). On the JVM you'd write catch Throwable e or, more typically, catch Exception e. The lesson: catch as narrowly as you can. Swallowing every error makes bugs invisible.

Where validation lives

The earlier "modeling with values" lesson said: validate at the boundary, trust the value internally. That belongs to the same family as this rule. A validator at the input layer returns errors (so the response handler can render a 400). Inner code that hits an "impossible" branch throws (so a bug is loud, not silently coerced to an HTTP 200 with bad data).

In a Clojure service

Expected failures — invalid amounts, missing fields — belong in return values or ex-info with a :type key the middleware can read. Unexpected failures — programmer bugs, infrastructure outages — stay as exceptions and surface as 500s. A wrap-ex-info->json middleware catches structured exceptions at the outermost boundary and renders a JSON body, keeping handlers free of response-formatting logic. Inner domain code throws with :type :validation; the shell translates that to {:status 400 ...}.

(defn deposit! [account amount]                    ;; sketch
  (when (neg? amount)
    (throw (ex-info "invalid amount"
                    {:type :validation :reason :negative-amount}))))
 
(defn wrap-ex-info->json [handler]                 ;; sketch middleware
  (fn [req]
    (try (handler req)
         (catch Exception e
           (let [{:keys [type] :as data} (ex-data e)]
             {:status (if (= type :validation) 400 500)
              :body   {:error type :details data}})))))

Handler golden path

A production handler is four layers, always in this order. Coercion and schema validation happen at the boundary (Reitit :parameters or Malli on the route). The core is pure: it takes plain data and returns [:ok value] or [:error info] — or a decision map with no I/O. The shell handler wires read → core → write; no business i‌f branches belong here. Middleware maps structured failures to HTTP status and JSON once, at the edge.

HTTP request
coerce / validate (boundary)
core (pure)
shell (persist, audit, side effects)
response map (status + body)

The Railway-oriented programming lesson names the bind step; this section shows the full stack including status codes. See also Web stack: Ring and Reitit for where coercion lives in route data.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Check yourself

? quiz

Which signature is the worst fit for a function that parses user-supplied dates?

Exercise

Write a pipeline helper that takes an initial value and a sequence of functions; each function returns either [:ok v] or [:error info]. The helper threads v through the chain, short-circuiting on the first error. Edit pipeline in the Repl below; run until every assert passes.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
Reference solution
(defn pipeline [init fs]
  (reduce
    (fn [acc f]
      (if (= :ok (first acc))
        (f (second acc))
        (reduced acc)))
    [:ok init]
    fs))
 status: new