Practice writing small, reviewable Clojure macros by controlling evaluation, avoiding variable capture, keeping runtime logic in functions, and inspecting expansions before trusting the abstraction.
Macro practice should train judgment, not just syntax. A useful macro changes the call-site shape in a way a function cannot, expands into ordinary readable Clojure, and keeps runtime logic testable.
Use these exercises from a REPL. For each macro, inspect the expansion before you run it.
| Rule | Why it matters |
|---|---|
Expand first with macroexpand-1. |
You need to review generated code before trusting it. |
| Test with side effects. | Atoms and logging reveal repeated evaluation. |
| Use generated locals. | Macro-owned names should not capture caller names. |
| Move runtime work into functions. | Functions are easier to test than large generated forms. |
Write a macro named log-once that prints the caller’s expression and value, then returns the value.
1(log-once (+ 1 2))
2;; prints something like: (+ 1 2) => 3
3;; returns: 3
Acceptance checks:
| Check | Expected result |
|---|---|
(log-once (swap! counter inc)) |
The atom increments once, not twice. |
(macroexpand-1 '(log-once (+ 1 2))) |
The caller expression appears in one evaluated position. |
| Temporary names | Generated with # or gensym. |
Starter shape:
1(defmacro log-once [expr]
2 ;; Replace this with a syntax-quoted expansion.
3 nil)
Review hint: bind ~expr once, then print the quoted form and generated value.
Write a macro named with-timing that runs a body, prints elapsed milliseconds, and returns the body’s final value.
1(with-timing "load users"
2 (load-users db)
3 :done)
4;; returns: :done
Acceptance checks:
| Check | Expected result |
|---|---|
| Body has multiple forms. | All forms run in order. |
| Body returns a value. | The final body value is returned. |
| Body throws. | Timing still prints from finally, then the exception propagates. |
Starter shape:
1(defmacro with-timing [label & body]
2 ;; Use try/finally and generated locals.
3 nil)
Review hint: the macro should arrange control flow. If you want to format or publish timing data, put that behavior in a helper function.
Create a small macro named routes that turns compact route specs into data.
1(routes
2 [:get "/health" health-handler]
3 [:post "/users" create-user!])
Target shape:
1[{:method :get
2 :path "/health"
3 :handler health-handler}
4 {:method :post
5 :path "/users"
6 :handler create-user!}]
Acceptance checks:
| Check | Expected result |
|---|---|
| Expansion size | Small enough to read. |
| Runtime validation | Kept in a function, not hidden inside the macro. |
| Handler symbols | Remain ordinary Clojure symbols in the generated code. |
Starter shape:
1(defmacro routes [& specs]
2 ;; Generate a vector of route maps.
3 nil)
Review hint: if plain data is already readable for your team, do not write the macro. The exercise is about recognizing the boundary.
Write a macro named with-context that catches an exception, attaches a runtime context map and the quoted body form, then rethrows.
1(with-context {:job-id job-id}
2 (import-users! db file))
Acceptance checks:
| Check | Expected result |
|---|---|
| Exception data | Includes :context and :form. |
| Original cause | Preserved as the cause of the new exception. |
| Logging | Optional and delegated to a helper function. |
Starter shape:
1(defmacro with-context [context & body]
2 ;; Build a quoted form value outside the syntax quote.
3 nil)
Review hint: the macro is justified only if the diagnostic needs the original body form. If a context map and exception are enough, use a function.
| Question | Pass condition |
|---|---|
| Can you explain the expansion aloud? | Yes, without relying on the macro name. |
| Does any caller expression appear twice? | No, unless repeated evaluation is the point. |
| Are macro-owned locals generated? | Yes. |
| Could a function solve the same problem more clearly? | No, or the macro should be deleted. |