Learn how Clojure macros should validate call syntax, throw useful expansion-time exceptions, and keep generated runtime errors close to the caller's mistake.
Macro errors are harder to diagnose when they point at generated code instead of the bad call site. A production macro should fail early when the call syntax is invalid and generate ordinary runtime errors when the generated code fails.
The first distinction is important:
| Error kind | Happens when | Good response |
|---|---|---|
| Invalid macro syntax | During expansion | Throw ex-info with the bad form or arguments. |
| Generated runtime failure | When expanded code runs | Keep generated code small and stack traces readable. |
| Internal macro bug | During expansion or runtime | Add expansion tests and shrink the macro. |
Do not let a malformed macro call produce a confusing expansion.
1(defmacro with-required
2 [binding & body]
3 (when-not (and (vector? binding)
4 (= 2 (count binding)))
5 (throw (ex-info "with-required expects [name expr]"
6 {:binding binding})))
7 (let [[name expr] binding]
8 `(let [value# ~expr]
9 (when (nil? value#)
10 (throw (ex-info "Required value was nil"
11 {:name '~name})))
12 (let [~name value#]
13 ~@body))))
This macro checks its call syntax before generating code. If the caller writes (with-required user ...), the error explains the expected shape.
ex-info carries structured data, which is more useful than a string alone.
| Data key | Why include it |
|---|---|
:binding |
Shows the malformed input shape. |
:form |
Useful when the macro receives the whole form. |
:expected |
Documents the contract in machine-readable data. |
:macro |
Helps when multiple macros share validation helpers. |
For Java engineers, think of this like throwing IllegalArgumentException with fields you can inspect, not only a message.
assert can be disabled depending on runtime settings and is usually too blunt for public macro contracts. Use explicit checks and throw for call-shape validation.
1(when-not (symbol? name)
2 (throw (ex-info "Expected a symbol name"
3 {:macro 'defhandler
4 :name name})))
Use assertions for internal invariants if your team already treats them that way, but do not rely on them as user-facing macro validation.
Generated code should fail like ordinary Clojure where possible.
1(with-required [user (find-user id)]
2 (:email user))
If find-user returns nil, the generated code throws a clear ex-info naming the missing binding. If (:email user) fails later because of a domain problem, that runtime failure should remain visible as normal application behavior.
| Check | Good answer |
|---|---|
| Does the macro validate call shape? | Yes, before generating code. |
| Does the exception name the macro contract? | Yes, message plus ex-info data. |
| Does generated code stay small? | Yes, stack traces are not buried in a giant expansion. |
| Are error cases tested? | Yes, invalid syntax and representative runtime failures. |