When ClojureScript Macros Meet Node.js: A Practical Guide
Learn how ClojureScript’s `defmacro` expands at compile time, how it behaves across browser, Node.js, and React Native targets, and how to verify macro output before shipping. Includes a concrete example, trade‑offs, and actionable steps.
09 Feb 2026, 13:00 UTC

Problem: Do macros work the same in Node.js as in the browser?
When you write a ClojureScript library that will run in both a web browser and a Node.js environment, you might wonder whether a macro that expands to a call to a global variable will still work. The answer depends on how the macro is written, how the build target is configured, and how the JavaScript module system is emitted. Engineers often assume that a macro is a purely compile‑time transformation, but the generated JavaScript can look different across targets, and that difference can break code that expects a global.
Thesis: ClojureScript’s `defmacro` is stable, but the emitted JavaScript depends on the target’s module strategy.
The core macro system has been unchanged since 2011: a macro receives the unevaluated form via the &form binding, expands into valid Clojure forms, and the compiler turns that into JavaScript. What changes is the surrounding JavaScript context – the way modules are loaded and how globals are referenced. Understanding this distinction lets you write portable macros and verify them reliably.
1. How a macro receives its input
A macro is defined with defmacro:
(defmacro log [& args]
`(js/console.log ~@args))
When you call (log "hello"), the compiler passes the raw syntax tree (log "hello") as the value of &form. Inside the macro you can inspect or transform that tree. The macro returns a new form that the compiler will treat as normal Clojure code.
2. Cross‑target differences in generated JavaScript
The JavaScript produced for log above looks identical whether you target :browser or :nodejs:
console.log("hello");
But if the macro refers to a global that only exists in the browser, like window, the emitted code will differ:
(defmacro get-window
[]
`(js/window))
In a browser build, the compiler emits window. In a Node.js build, window is undefined, and the compiler will still emit window unless you guard the macro with a reader conditional:
(defmacro get-window
[]
#? (:browser (js/window)
:nodejs (js/global)))
Reader conditionals (#?) allow the same source file to compile differently per target without runtime checks.
3. Practical verification steps
- Inspect the expansion in a REPL. In a ClojureScript REPL (clj or shadow‑cljs), run:
This prints the form that the compiler will see, e.g.(macroexpand-1 '(log "test"))(js/console.log "test"). - Build a minimal project. Use
shadow-cljsorcljs.build.apito compile to JavaScript. Open the output file and locate the macro call site. Verify the generated JavaScript matches what you expect. - Cross‑compare versions. Compile the same source with ClojureScript 1.10.5 and 1.11.0. The macro expansion should be identical; any differences usually come from the compiler’s dead‑code elimination in newer versions.
4. Trade‑off: Debugging macro‑expanded code
Macros can hide the original source, making stack traces hard to read. Because the compiler emits source maps, you can still map back to the original line, but the mapping is one‑to‑many: a single macro call may expand to many JavaScript statements. The recommended practice is to keep macros small and pure, avoid side‑effects, and always test the expanded form before shipping.
5. Actionable closing
When building a ClojureScript library for both browser and Node.js:
- Write macros that return only ClojureScript forms, not raw JavaScript strings.
- Guard target‑specific globals with reader conditionals or
cljs.core/whenaround#?(:nodejs :browser)checks. - Use
macroexpand-1in the REPL to preview expansions before committing. - After each change, run a quick build for both targets and inspect the emitted JavaScript.
- Keep an eye on compiler warnings – aggressive dead‑code elimination in 1.11+ can strip code you rely on if it’s not referenced in the expanded form.
Following these steps ensures your macros behave consistently across environments and that you can catch subtle differences early in the development cycle.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.