Rewrite nested Clojure data with a declared shape.
over compiles a selector and rewrite body into code in the style of update/mapv/update-vals, but keeps the shape in one place. It visits only what you select.
(over selector body) ; => rewritten value
(compile-over selector body) ; => fn of one argument
(over-plan selector body) ; => compiler plan data(def data
{:a [{:aa 1 :bb 2}
{:cc 3}]
:b [{:dd 4}]})
(over [{_ [{_ n}]} data]
{n (cond-> n (even? n) inc)})
;; => {:a [{:aa 1 :bb 3} {:cc 3}]
;; :b [{:dd 5}]}Map + vector traversal with one binding.
Plain Clojure:
(update-vals data
#(mapv (fn [m]
(update-vals m (fn [n] (cond-> n (even? n) inc))))
%))(require '[clojure.string :as str])
(def users
{:alice {:active true :email "ALICE@EXAMPLE.COM"}
:bob {:active false :email "bob@example.com"}
:cara {:active true :email "CARA@EXAMPLE.COM"}})
(over [{_ {:keys [active email] :as u}} users]
{u? active
email (str/lower-case email)})
;; => {:alice {:active true :email "alice@example.com"}
;; :cara {:active true :email "cara@example.com"}}Map-entry traversal with a guard (u?) to drop entries.
Plain Clojure:
(->> users
(reduce-kv (fn [m id u]
(if (:active u)
(assoc m id (update u :email str/lower-case))
m))
{}))(def order
{:id 42
:lines [{:sku "A" :qty 2}
{:sku "" :qty 1}
{:sku "B" :qty 0}]})
(over [{:keys [lines]} order
[{:keys [sku qty] :as line}] lines]
{line? (seq sku)
qty (or qty 0)})
;; => {:id 42
;; :lines [{:sku "A" :qty 2}
;; {:sku "B" :qty 0}]}Sequential traversal; each element can be rewritten or dropped.
Plain Clojure:
(update order :lines
(fn [lines]
(->> lines
(filter (comp seq :sku))
(mapv #(update % :qty (fnil identity 0))))))A selector is a vector of alternating pattern and source:
(over [pattern1 source1
pattern2 source2
...]
body-map)- The first
sourceis the input expression. - Later sources must be previously bound symbols.
- The selector looks like destructuring, but it is a query: it binds and traverses; it does not introduce locals the way Clojure destructuring does.
- Map-entry traversal:
{kpat vpat}- Requires a map value; iterates entries.
kpatbinds the key (no traversal).vpatmay traverse.
- Sequential traversal:
[pat]- Requires a sequential or set value; iterates elements.
- Plain symbol:
sym- Binds the current value.
- Map destructuring form:
{:keys [...], :strs [...], :as sym, :or {...}}- No traversal; binds keys and/or
:as. - Nested destructuring inside
:keys/:strsis rejected.
- No traversal; binds keys and/or
- Records are treated as maps and may come back as plain maps after rewrite.
Unsupported or ambiguous patterns are rejected with ex-info including :phase, :path, and :pattern.
The body is a map from target symbols to expressions.
- A key
xrewrites the bindingx. - A key
x?is a guard. When false, the corresponding structural position is elided.
Elision rules:
- Map-entry traversal: false guard removes the entry.
- Sequential traversal: false guard removes the element.
- Map destructure key: false guard removes that key.
:asbinding: false guard removes the whole element at that level.
Traversal is post-order: children are processed before parents. Guards see post-child values.
Plain Clojure is already a win when:
- A couple of
updatecalls are clearer than any DSL. update-vals+mapvis easier to scan for simple shapes.
clojure.walk/postwalk is better when:
- You need full-tree traversal.
- You don’t know the shape ahead of time.
over fits when:
- You have a known shape and want to touch a few nested spots.
- You want the traversal shape and the rewrite adjacent.
- You want compiled code that avoids full walks and keeps structural sharing.
- Not a general tree rewriting engine.
- Not a replacement for all data transformation.
- Not arbitrary destructuring: selectors are queries, not local binding forms.
- No
:letor computed traversal paths.
over-planreturns the compiler plan as data. Useful for debugging.
clojure -M:testclj -M:lint --lint src test
clj -M:fmtclj -M:checkMIT