~ / track B / clojure advanced

Metadata and reader conditionals

Intermediate

Two 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:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

^:foo is shorthand for ^{:foo true}. You can attach a full map:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Or use with-meta (returns a copy) and vary-meta (returns a copy with the meta updated by a function):

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Crucially, metadata does not affect equality:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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. d‌e‌f‌n and d‌e‌f attach it for you:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

: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 b‌i‌n‌d‌i‌n‌g.
  • :tag — type hint for the compiler (only meaningful on the JVM).
  • :deprecated — your tools and humans can spot it.
loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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 …):

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
 status: new