4 minute read

Every so often a question comes along that seems too basic to be interesting, and turns out to be exactly the opposite. “Is def a function or a macro?” is one of those questions. The honest answer is: neither. And once you sit with why, you end up staring straight at the floor Clojure is built on.

The trap of the obvious answer

If you’ve been writing Clojure for a while, your instinct probably says “macro.” After all, def looks like it’s doing something at compile time — it takes a raw symbol, x, and doesn’t evaluate it the way a normal function would evaluate its arguments. That’s macro-like behavior, right?

Except it isn’t, and here’s the tell: macros are things you can expand. Try it:

(macroexpand '(def x 1))
;=> (def x 1)

Nothing happens. You get the same form back, unchanged. A real macro — when, ->, defn — rewrites itself into something else when you expand it. def refuses to rewrite into anything, because there’s nowhere lower to go. It’s already at the bottom.

That bottom has a name: special form.

Three categories, not two

It’s tempting to think of Clojure code as just “functions and macros,” but there’s a third, smaller, more fundamental category sitting underneath both:

  • Functions are ordinary values. You can pass them around, store them, apply them, take their class. Nothing about them is syntactically special — map is a value like 5 or "hello" is a value.
  • Macros are code-rewriting rules. They exist in terms of other code. Every macro, when you peel back the expansion, eventually bottoms out in function calls and special forms. when is a macro; expand it and you’ll find if waiting underneath.
  • Special forms are the primitives the evaluator itself understands. They’re not defined in Clojure — they’re hardcoded into the compiler. def, if, do, quote, fn*, let*, loop*, recur, throw, try, var, new, set!, the dot special form — this is the closed, small set everything else is built from.

def has to be in that third bucket because of what it does: it creates a Var and binds it into a namespace, using the symbol itself, unevaluated, as the name — with full knowledge of which namespace you’re currently compiling in. No ordinary function can do that, because ordinary functions evaluate all their arguments before they ever get a look at them. By the time a function saw x, it’d already have been evaluated to whatever x currently means (or it’d throw, since x isn’t defined yet — that’s the whole point of def).

You can ask Clojure directly, instead of reasoning it out:

(special-symbol? 'def)  ;=> true

So how do you tell the other two apart?

Once you accept that special forms are their own thing, a natural follow-up shows up: fine, but how do I check whether some other symbol is a function or a macro? Is there a macro? to go with special-symbol?

There isn’t one built into clojure.core — which surprised me a little — but it’s a one-liner, because macro-ness is just metadata on the Var:

(:macro (meta #'when))  ;=> true
(:macro (meta #'map))   ;=> nil

That’s the whole trick. Wrap it in a function if you want a name for it:

(defn macro? [sym]
  (:macro (meta (resolve sym))))

Functions are checked differently, and the difference matters conceptually: macros are a property of the symbol/Var, but functions are a property of the value the symbol resolves to. So you resolve first, then ask the value what it is:

(fn? @(resolve 'map))  ;=> true

fn? is the direct check. There’s also ifn?, which is broader — it’s true for anything invocable, including keywords and sets used as lookup functions, not just genuine fn values.

Putting the whole picture together

Every top-level symbol in Clojure falls into exactly one of three buckets, and you can check each one explicitly:

Category Check
Special form (special-symbol? sym)
Macro (:macro (meta (resolve sym)))
Function (or other value) (fn? @(resolve sym))

Which gives you a nice little classifier:

(defn classify [sym]
  (cond
    (special-symbol? sym) :special-form
    (:macro (meta (resolve sym))) :macro
    (fn? @(resolve sym)) :function
    :else :value))

(classify 'def)   ;=> :special-form
(classify 'when)  ;=> :macro
(classify 'map)   ;=> :function
(classify 'nil)   ;=> :value (resolve fails here actually, since nil isn't a var)

One honest caveat: resolve only works on symbols that name Vars in some namespace. Local bindings, literals, and destructured names will throw or return nil. This classifier is for top-level, namespace-resident names — not arbitrary forms you might type at a REPL.

Why this is worth caring about

It would be easy to file this under trivia. I don’t think it is. The three-tier structure — special forms at the bottom, macros built on top of them, functions as ordinary values sitting alongside — is the whole reason Clojure’s syntax stays so small while its expressive power doesn’t. defn, let, ->>, cond, nearly everything you reach for daily, is a macro that eventually expands down into that tiny, closed set of nineteen-ish special forms. Understanding where the floor is — and that def lives on it — is understanding why the language holds together at all.

Updated: