~ / track B / clojure advanced
Metadata and reader conditionals
IntermediateTwo Clojure features that look like syntax noise the first time you meet them, but earn their keep the moment you start writing tools, libraries, or cross-platform code. Metadata lets you attach information to a value without changing the value itself. Reader conditionals let one source file compile to JVM Clojure, ClojureScript, or Babashka by branching at read time. Both rely on the reader (the part of the compiler that turns source text into data structures).
Metadata: data about data
Metadata is a map attached to a value. It travels with the value, doesn't participate in equality, and any Clojure tool — the compiler, your IDE, tests, your own code — can read it.
The simplest way to attach it is the reader's ^ syntax:
^:foo is shorthand for ^{:foo true}. You can attach a full map:
Or use with-meta (returns a copy) and vary-meta (returns a copy with
the meta updated by a function):
Crucially, metadata does not affect equality:
Two vectors with different metadata are still = if their elements match.
That's the whole point — metadata is "off to the side."
Metadata on vars
The most common place you'll see metadata used is on vars. defnclojure.core/defnDefine a named function.view on clojuredocs → and defclojure.core/defBind a name to a value in the current namespace.view on clojuredocs → attach it for you:
:private here is a convention the compiler honors — it stops other
namespaces from referring to the var without a #' workaround. Other
common keys:
:doc— documentation string.(def ^{:doc "hi"} y 1)is how docstrings literally work;(defn foo "docs" […] …)is sugar for the same map.:dynamic— marks a var as rebindable via bindingclojure.core/bindingDynamic, thread-local rebinding of dynamic vars.view on clojuredocs →.:tag— type hint for the compiler (only meaningful on the JVM).:deprecated— your tools and humans can spot it.
Metadata on functions, records, and seqs
Functions can carry meta (used heavily by spec, malli, and instrumentation
tools). Records carry it on each instance. Most seq operations don't
preserve metadata on the result — (map inc ^:foo [1 2 3]) returns a seq
with no meta — so when you need it, use vary-meta or with-meta to
re-attach explicitly.
Reader conditionals: one file, many platforms
Clojure runs on three official platforms: the JVM (Clojure), JavaScript
(ClojureScript), and the GraalVM/Babashka world for scripts. Most code is
platform-neutral, but every library eventually hits something that isn't
— Date, Math/floor, a js/setTimeout, a java.io.File. The fix is a
reader conditional.
The syntax is #?(:keyword form …):
Only the branch matching the current platform is read; the rest is
discarded by the reader before the compiler ever sees it. This page runs
in the browser, so :cljs-side comes back.
The splice form #?@(:cljs [foo bar]) interpolates a sequence of forms,
which is convenient inside a vector or function call:
(defn now []
#?(:clj (java.time.Instant/now)
:cljs (js/Date.)))
(defn parse-number [s]
#?(:clj (Long/parseLong s)
:cljs (js/parseInt s 10)))Reader conditionals only work in files with the .cljc extension — a
.clj file is JVM-only by definition, and .cljs is JS-only. The .cljc
extension is the contract that says "this file is cross-platform."
Where this matters in practice
The split-per-platform pattern keeps shared logic in .cljc files and pushes
platform calls to a small .clj/.cljs pair:
src/
myapp/core.cljc ;; pure logic, runs anywhere
myapp/io.cljc ;; thin #? branches over Date, fs, http
myapp/jvm_only.clj ;; JVM-specific tools (file watching, JDBC)
myapp/web_only.cljs ;; browser-specific (DOM, fetch)Most of the value is in the discipline, not the syntax: by isolating the platform calls, you make the rest of the codebase trivial to port, benchmark, or test in isolation.
A practical use: tagging tests
Tools rely on metadata as the public way to mark intent. clojure.test's
:focus/:skip keys, Malli's instrument markers, your CI runner's "only
run integration tests" filter — all of them are reading metadata you attach
once at the source. Here's the shape of the pattern:
The function still works the same — calling (annotated-slow) returns
:took-a-second. The meta is the side-channel the test runner queries to
decide whether to run it under --fast mode.
Check yourself
? quiz
Two vectors `^:foo [1 2 3]` and `[1 2 3]` — what does `=` return for them?
Exercise
Write a function tag that takes a value v and a keyword k, and
returns a copy of v with {k true} merged into its metadata. Then write
tagged? that returns true when its argument carries the tag. The pair
should compose, so (tagged? (tag [1 2] :foo) :foo) is true.