Find and transform values in nested data structures.
Clojure:
ClojureScript:
;; deps.edn
co.multiply/pathling {:mvn/version "0.1.4"}
Pathling is designed to be efficient at two-phase updates of a Clojure data structure. Take this scenario:
In this scenario, Pathling is an appropriate solution. It will efficiently find the values for you, and give them to you in a vector (appended depth-first). It will also give you a navigation object which you can use to do targeted updates to the values within the original data structure, wherever they were found.
For example, consider finding all elements matching a certain criteria in a data structure, where you want to give them a label like "1 out of n". Perhaps you don't know (or perhaps don't care about) how many there are, or where exactly they are. In this case, you'd want to collect them all, do some transformation on them collectively, and then put them back.
(require '[co.multiply.pathling :as p])
(def data {:items [{:type :task, :name "Write docs"}
{:type :note, :name "Remember milk"}
{:type :task, :name "Fix bug"}]
:nested {:deep {:type :task, :name "Review PR"}}})
;; Find all tasks, wherever they are
(def result (p/path-when data #(= :task (:type %))))
(:matches result)
;=> [{:type :task, :name "Write docs"}
; {:type :task, :name "Fix bug"}
; {:type :task, :name "Review PR"}]
;; Label them "1 of 3", "2 of 3", etc.
(let [n (count (:matches result))
labels (into {} (map-indexed (fn [i task]
[task (str (inc i) " of " n)])
(:matches result)))]
(p/update-paths data (:nav result)
#(assoc % :label (labels %))))
;=> {:items [{:type :task, :name "Write docs", :label "1 of 3"}
; {:type :note, :name "Remember milk"}
; {:type :task, :name "Fix bug", :label "2 of 3"}]
; :nested {:deep {:type :task, :name "Review PR", :label "3 of 3"}}}
By efficient means:
To give some kind of comparison to postwalk in particular:
| Operation | Pathling | Postwalk | Diff |
|---|---|---|---|
| find | 211 µs | 1.92 ms | 9x |
| - objects/call | 138 | 5,246 | 38x fewer |
| - bytes/call | 7.1 KB | 176 KB | 25x fewer |
| transform | 305 µs | 1.95 ms | 6x |
| - objects/call | 407 | 5,240 | 13x fewer |
| - bytes/call | 21.4 KB | 174 KB | 8x fewer |
Measured on an M1 in Clojure, on a randomly generated structure 6 levels deep, with a branch factor of 5 at each level. The data structure consists of vectors, maps, and sets. This results in ~10,000 nodes, out of which ~300 are matches. Criterium and YourKit were used to estimate performance and allocation count.
Pathling's performance scales with match count rather than structure size. The speedup advantage grows as matches become sparser relative to the overall structure. Postwalk always visits every node regardless of how many match.
Note that efficiency claims are mostly about Clojure. Less attention has been given to the ClojureScript equivalent, and it could be improved from its current state.
Key properties:
find-when accepts transducers for transformation, filtering, and early terminationREMOVE sentinel: Conditionally remove elements during transformationpath-whenFind all values matching a predicate, returning both matches and a navigation structure for updates.
(require '[co.multiply.pathling :as p])
(def data [1 {:a 2} {:b #{3 {:c 4}}}])
(p/path-when data number?)
;=> {:matches [1 2 3 4]
; :nav <navigation-structure>}
;; No matches returns nil
(p/path-when [:a :b :c] number?)
;=> nil
Options:
:include-keys - When true, also match map keys (default: false)update-pathsApply a function to all locations identified by a navigation structure.
(let [{:keys [nav]} (p/path-when data number?)]
(p/update-paths data nav inc))
;=> [2 {:a 3} {:b #{4 {:c 5}}}]
find-whenFind values without building navigation (more efficient for read-only operations). Supports transducers for transformation, filtering, and early termination.
(p/find-when data number?)
;=> [1 2 3 4]
;; With transducer
(p/find-when data number? (map inc))
;=> [2 3 4 5]
;; Early termination - stops scanning after finding 2 matches
(p/find-when data number? (take 2))
;=> [1 2]
;; Stateful transducers work too
(p/find-when [1 2 3 4 5 6] number? (partition-all 2))
;=> [[1 2] [3 4] [5 6]]
;; Composing transducers
(p/find-when data number? (comp (filter even?) (map (partial * 10))))
;=> [20 40]
;; "Pagination": skip 2, take 2
(p/find-when (vec (range 10)) number? (comp (drop 2) (take 2)))
;=> [2 3]
;; With options map
(p/find-when {:a 1 :b 2} keyword? {:include-keys true :xf (map name)})
;=> ["a" "b"]
The pred argument filters at scan time (in tight loops), while transducers process matches. You could achieve the
equivalent effect with e.g. (find-when data (constantly true) (filter pred)). Filtering up-front, before engaging
the transducer machinery, is ultimately faster for the case where you don't want to pipe the entire structure through
the transducer (which ought to be most cases).
If no transducer is given, a more efficient method of collecting matches is used.
transform-whenProduces a navigation object internally, then applies the transformation given. Does not collect matches.
Utility function for convenience. Pathling isn't necessarily the most efficient alternative if you don't need to handle the intermediate collection of matches. It might be, if the matches are sparse. Measure, if performance matters.
(p/transform-when data number? inc)
;=> [2 {:a 3} {:b #{4 {:c 5}}}]
;; Transform map keys
(p/transform-when {:a 1 :b 2} keyword? name {:include-keys true})
;=> {"a" 1 "b" 2}
Return REMOVE from a transform function to remove elements from their parent collection.
;; Remove negative numbers, increment positive ones
(p/transform-when [1 -2 3 -4 5] number?
(fn [n]
(if (neg? n)
p/REMOVE
(inc n))))
;=> [2 4 6]
;; Filter maps from a vector
(p/transform-when [{:keep true} {:keep false} {:keep true}]
#(and (map? %) (not (:keep %)))
(constantly p/REMOVE))
;=> [{:keep true} {:keep true}]
Behavior by collection type:
Collection metadata is preserved through transformations:
(let [data (with-meta {:a 1 :b 2} {:version 1})]
(meta (p/transform-when data number? inc)))
;=> {:version 1}
Pathling handles all standard Clojure collections:
Sorted collections (sorted-map, sorted-set) preserve their type and comparator through transformations.
MIT License. Copyright (c) 2025 Multiply. See LICENSE.
Authored by @eneroth
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |