Skip to content
AgileLogics
6 min read

Error Handling

Prerequisites: Functions, Maps

Clojure uses the JVM's exceptions, with try, catch, and finally, plus a convenient way to attach data to errors.

(defn parse-age [s]
  (let [n (parse-long (str s))]
    (cond
      (nil? n) (throw (IllegalArgumentException. (str "not a number: " (pr-str s))))
      (neg? n) (throw (IllegalArgumentException. "age cannot be negative"))
      :else n)))

(doseq [input ["42" "abc" "-5"]]
  (try
    (println "age =" (parse-age input))
    (catch IllegalArgumentException e
      (println "error:" (ex-message e)))
    (finally
      (println "  (checked)"))))
age = 42
  (checked)
error: not a number: "abc"
  (checked)
error: age cannot be negative
  (checked)
  • (throw exception) raises; (ClassName. args) constructs a Java object.
  • catch clauses name an exception class; the first matching one runs.
  • finally always runs — the place for cleanup.
  • Clojure has no checked exceptions, like Groovy and Kotlin.

ex-info: errors with data

(defn withdraw [account amount]
  (if (> amount (:balance account))
    (throw (ex-info "Insufficient funds"
                    {:type :insufficient-funds
                     :balance (:balance account)
                     :requested amount}))
    (update account :balance - amount)))

(println (withdraw {:id 1 :balance 100} 30))

(try
  (withdraw {:id 1 :balance 100} 500)
  (catch clojure.lang.ExceptionInfo e
    (let [{:keys [type balance requested]} (ex-data e)]
      (println (ex-message e) type balance requested))))
{:id 1, :balance 70}
Insufficient funds :insufficient-funds 100 500

ex-info creates an exception carrying a map of data; ex-data reads it back. This is the idiomatic way to signal domain errors — no custom exception classes needed. Callers can decide what to do based on :type.

Catching broadly and wrapping

(defn load-config [text]
  (try
    (let [n (Long/parseLong text)]
      {:workers n})
    (catch NumberFormatException e
      (throw (ex-info "Bad config" {:input text} e)))))

(try
  (load-config "many")
  (catch Exception e
    (println (ex-message e) (ex-data e))
    (println "caused by:" (type (ex-cause e)))))
Bad config {:input many}
caused by: java.lang.NumberFormatException

The third argument of ex-info is the cause, so the original exception isn't lost. (catch Exception e ...) catches everything except JVM Errors.

Errors as values

(defn safe-divide [a b]
  (if (zero? b)
    {:error :division-by-zero}
    {:ok (/ a b)}))

(doseq [[a b] [[10 4] [1 0]]]
  (let [{:keys [ok error]} (safe-divide a b)]
    (println (if error (str "failed: " (name error)) (str "result: " ok)))))
result: 5/2
failed: division-by-zero

For expected failures (validation, "not found"), returning a map is often clearer than throwing. Save exceptions for truly exceptional situations.

Practice

Write fetch-user that throws ex-info with {:type :not-found :id id} when the id isn't in a map of users, and a caller that prints "user 7 not found".

Show solution
(def users {1 "Ada" 2 "Linus"})

(defn fetch-user [id]
  (or (get users id)
      (throw (ex-info "User not found" {:type :not-found :id id}))))

(println (fetch-user 1))
(try
  (fetch-user 7)
  (catch clojure.lang.ExceptionInfo e
    (when (= :not-found (:type (ex-data e)))
      (println "user" (:id (ex-data e)) "not found"))))
Ada
user 7 not found