~ / track D / clojure in practice
Error handling: exceptions vs values
IntermediatepracticeClojure'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:
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 yourcatcharm 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-msgif 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.
Simple, idiomatic, lets when-letclojure.core/when-letBind only when the test value is truthy.view on clojuredocs →/if-letclojure.core/if-letLike let, but only enters the then branch when bound value is truthy.view on clojuredocs → work naturally. The cost: you lose the reason for the failure.
Return a tagged tuple [:ok v] / [:error info].
The caller caseclojure.core/caseConstant-time dispatch on compile-time literals.view on clojuredocs →s 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 letclojure.core/letLocal bindings: (let [k v …] body).view on clojuredocs →.
Return a result map {:status … :value …}.
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 if-letclojure.core/if-letLike let, but only enters the then branch when bound value is truthy.view on clojuredocs → gets tedious. A small helper short-circuits on the first failure:
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:
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:
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 ifclojure.core/ifConditional: (if test then else?).view on clojuredocs → 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.
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.
Reference solution
(defn pipeline [init fs]
(reduce
(fn [acc f]
(if (= :ok (first acc))
(f (second acc))
(reduced acc)))
[:ok init]
fs))