wrap
map"map type maps"
wrap
maps provide a flexible way to create specialized map-like data structures in Clojure and ClojureScript. It allows you to intercept and customize standard map operations like get
, assoc
, dissoc
, function invocation, printing, and more. This enables built-in validation, side effects, lazy loading, default values, case-insensitive keys, and other custom behaviors without needing to reimplement all the underlying map interfaces.
Suppose you want to instrument a map so that you can debug something that is going on deep in some opaque pipeline you're working on:
(-> {:a 1}
(w/assoc
:assoc #(do (when (= :easter! %3) (prn :egg! :assoc %2)) (assoc %1 %2 %3))
:assoc! #(do (when (= :easter! %3) (prn :egg! :assoc! %2)) (assoc! %1 %2 %3))
:get #(let [r (get %1 %2)] (when (= :easter! r) (prn :egg! :get %2)) r))
(assoc :b 2)
#_...
transient
(assoc! :5ecr3t :easter!)
persistent!
#_...
(assoc :5ecr3t :redacted)
#_...
#_...
w/unwrap
(assoc :done 1))
; :egg! :assoc! :5ecr3t
{:a 1, :b 2, :5ecr3t :redacted, :done 1}
Now you can! And, notice, after unwrap
ing the map the instrumentation disappears. It's magic!
Think of it as adding middleware or aspects directly to your map data structure. It's similar to the proxy or decorator pattern, but more functional. It offers two ways to customize behavior:
:get
, :assoc
) to attach handlers for common map operations. Easier to use for most scenarios in applications or application specific data wrangling.:valAt_k_nf
, :T_assoc_k_v
). Useful for advanced cases or overriding methods not exposed by the high-level API. Prefer the low-level API when building libraries on top of wrap
maps. Low level API versions are guaranteed remain stable.Sometimes, you need a map that does more than just associate keys with values. You might want to:
assoc
'd.get
).IFn
) to perform a specific action based on its content.wrap
maps provide a structured and composable way to achieve these behaviors by wrapping a standard Clojure(Script) map and delegating operations through customizable handlers.
:get
, :assoc
, :dissoc
, etc.) or low-level method keys.:invoke
high-level keyword or low-level :invoke
.:print
high-level keyword or low-level keys.meta
, with-meta
).Developed and tested with Clojure 1.12.x and ClojureScript 1.11.x.
Add the following dependency:
deps.edn:
com.jolygon/wrap-map {:mvn/version "0.1.11"}
Require the main API namespace, aliased as w
.
(require '[com.jolygon.wrap-map :as w :refer [wrap]])
You create a wrap
map just like a regular map:
(def m1 (wrap :a 1 :b 2))
;=> {:a 1, :b 2}
;; It behaves like a standard Clojure(Script) map by default:
(get m1 :a) ;=> 1
(get m1 :c 404) ;=> 404
(:b m1) ;=> 2
(count m1) ;=> 2
(assoc m1 :c 3) ;=> {:a 1, :b 2, :c 3}
(dissoc m1 :a) ;=> {:b 2}
(keys m1) ;=> (:a :b)
(vals m1) ;=> (1 2)
;; It's persistent:
(def m2 (assoc m1 :c 3))
m1 ;=> {:a 1, :b 2}
m2 ;=> {:a 1, :b 2, :c 3}
;; Transient support works as expected:
(persistent! (assoc! (transient m1) :d 4))
;=> {:a 1, :b 2, :d 4}
Use w/assoc
to attach behavior handlers using simple keywords. The first argument is a wrap
map (or just a map), followed by keyword/handler pairs.
(def default-value-map
(-> (wrap :c 3)
(w/assoc :get (fn [m k & [nf]]
(get m k (or nf :not-available))))))
(def m-with-default (assoc default-value-map :a 1))
(get m-with-default :a) ;=> 1
(get m-with-default :b) ;=> :not-available
(get m-with-default :b :explicit-nf) ;=> :explicit-nf (uses provided not-found)
(m-with-default :b) ;=> :not-available (:invoke behavior defaults to :get)
;; Example 2: Case-Insensitive String Keys
(defn- normalize-key [k]
(if (string? k) (.toLowerCase ^String k) k))
(def case-insensitive-map
(-> {:other :keys :in :a :regular :map}
(w/assoc ;<- `w/assoc` and friends auto-`wrap` their map arg when needed
:assoc (fn [m k v] (assoc m (normalize-key k) v))
:dissoc (fn [m k] (dissoc m (normalize-key k)))
:contains? (fn [m k] (contains? m (normalize-key k)))
:get (fn [m k & [nf]] (get m (normalize-key k) nf)))))
(def headers (-> case-insensitive-map (assoc "Content-Type" "application/json")))
(get headers "content-type") ;=> "application/json"
(contains? headers "CONTENT-TYPE") ;=> true
(dissoc headers "Content-type") ;=> {:other :keys :in :a :regular :map}
;; Want to freeze a wrap map to its current implementation?
(def frozen-headers (w/freeze headers))
(w/assoc frozen-headers :get #(get %1 (.toUpperCase %2)))
; Execution error (ExceptionInfo) at com.jolygon.wrap_map.api_0.impl.WrapMap+assoc_k_v|valAt_k/_assoc_impl (impl.clj:797).
; Cannot set impls on frozen wrap map
The high-level w/assoc
function associates handler functions with specific behavior keywords. These keywords generally correspond to common map operations.
:get
: Overrides key lookup (get
, keyword invocation, map-as-function arity-1/arity-2).
(fn [m k] ...)
or (fn [m k nf] ...)
:assoc
: Overrides key/value association (clojure.core/assoc
).
(fn [m k v] ...)
:dissoc
: Overrides key removal (clojure.core/dissoc
).
(fn [m k] ...)
:contains?
: Overrides key presence check (contains?).
(fn [m k] ...)
:invoke
: Overrides map-as-function behavior for all arities.
(fn [m & args] ...)
:print
: Overrides how the map is printed (print-method
, str
).
(fn [m] ...)
When you use w/assoc
, it translates the behavior keyword (e.g., :get
) into one or more low-level implementation keys (e.g., :valAt_k
, :valAt_k_nf
) and registers your handler function appropriately using the low-level assoc-impl
mechanism. However, if a high level key is not available, w/assoc
behaves just like assoc-impl
, so you can use w/assoc
for both.
For finer control, direct access to underlying protocol/interface methods, or to implement behaviors not covered by the high-level keywords (like complex transient interactions or read-only maps), you can use the low-level API.
Structure: A WrapMap
internally holds:
e
: A persistent map where keys are reserved, specific unqualified keywords and values are functions that override default implementation for the method associated with the keyword.m
: The underlying map holding the actual data.Implementation Keys: Override functions are associated with namespace unqualified keyword keys.
:toString :-conj_v :-empty :-dissoc_k :-assoc_k_v :-contains-key?_k :-find_k :-seq :-meta :withMeta_new-meta :-count :-lookup_k :-lookup_k_nf :kv-reduce_f_init :invoke :invoke-variadic :-pr-writer_writer_opts
:T_-conj! :T_-assoc!k_v :T-dissoc!k :T-lookup_k :T_-lookup_k_nf :T_-count
:toString :containsKey_k :entryAt_k :assoc_k_v :kvreduce_f_init :valAt_k :valAt_k_nf :keyIterator :valIterator :count :empty :cons_v :assocEx_k_v :without_k :seq :iterator :invoke :invoke-variadic :asTransient :withMeta_meta :meta :coll-reduce_afn :coll-reduce_afn_init :kv-reduce_afn_init :size :isEmpty :containsValue_v :get_k :get_k_nf :entrySet :keySet :values :put :remove :putAll :clear :print-method_writer
:T_conj_v :T_assoc_k_v :T_without_k :T_valAt_k :T_valAt_k_nf :T_count
Override Function Signatures: Low-level override functions receive more arguments. They often need to return a variant of WrapMap
using the <-
constructor function - in the form of: (<- e m)
. TransientWrapMap
, on the other hand, handle's returning it's own this
on mutating operations - all you have to do is perform the mutating operations on the transient map (t_m
) and it will be returned. You cannot make changes to the implementations environment map e
while in transient mode. You are only provided e
for informational access to the implementations and metadata, for meta programming purposes.
Providing Low-Level Implementations: Use w/vary
or w/assoc
. w/assoc
can handle both high level and low level keys.
;; Example: Read-Only Map (Requires Low-Level API)
(defn read-only-error [& _]
(throw (UnsupportedOperationException. "Map is read-only")))
(def read-only-map-impls
{:assoc_k_v read-only-error ;; Override persistent assoc
:without_k read-only-error ;; Override persistent dissoc
:cons_v read-only-error ;; Override persistent conj
:assocEx_k_v read-only-error
;; Override transient mutations too
:T_assoc_k_v read-only-error
:T_without_k read-only-error
:T_conj_v read-only-error})
(def read-only-m
(-> (wrap :a 1)
(w/vary merge read-only-map-impls)))
;; Usage
(get read-only-m :a) ;=> 1
(try (assoc read-only-m :b 2) (catch Exception e (.getMessage e)))
;=> "Map is read-only"
(try (persistent! (assoc! (transient read-only-m) :c 3)) (catch Exception e (.getMessage e)))
;=> "Map is read-only"
;; Example 2 - surgical modifications (here logging) in a functional pipeline
(-> {:a 1}
(assoc :b 2)
(w/assoc
:T_assoc_k_v (fn [_ t-m k v]
(println "[Transient] assoc! key:" k "val:" v)
(assoc! t-m k v)))
transient
(assoc! :x 100)
(assoc! :y 200)
persistent!
w/unwrap
(dissoc :b)
(w/assoc
:assoc_k_v (fn [{:as e :keys [<-]} m k v]
(println "[Persistent] assoc key:" k "val:" v)
(<- e (assoc m k v)))) ;<- persistent ops require `<- constructor
(assoc :z 300)
w/unwrap
(assoc :done 1))
; [Transient] assoc! key: :x val: 100
; [Transient] assoc! key: :y val: 200
; [Persistent] assoc key: :z val: 300
{:a 1, :x 100, :y 200, :z 300, :done 1}
For more detailed examples covering both APIs, see:
w/assoc
with keywords)w/vary
, etc.)Significant performance optimizations have been implemented, including specializing internal types and optimizing constructors.
wrap
map operations (reads, writes, construction, reduction, batch transient updates) now perform very close to, and sometimes exceed, the speed of standard Clojure/Script hash maps and transients.wrap
maps at ~95% the speed of standard maps.wrap
maps at ~72% the speed of standard maps, heavily influenced by the persistent!
cost. Many individual CLJS operations (writes, reductions) were faster than standard maps.wrap
back to a persistent one (persistent!
), especially in ClojureScript.wrap
map operations, which is expected. However, the baseline is now much faster.See ./bench/ex/clj-bench.md for Clojure benchmark details and ./bench/ex/cljs-bench.md for ClojureScript benchmark details. Contributions for further optimization are welcome!
def-map-type
): Potemkin's def-map-type
is excellent for creating new, specific map-like types that efficiently implement map interfaces, often based on delegating to underlying fields or structures. Choose def-map-type
when you need a new, static, record-like data type with map semantics. Choose wrap
maps when you want to add dynamic behaviors (validation, logging, computation, interception) to existing map data or general-purpose map structures without defining a whole new type, or when you want to change behaviors dynamically using assoc-impl
/vary
.defrecord
/ deftype
: Suitable for creating fixed-schema, efficient data structures. They can implement protocols for map-like behavior, but you implement the methods directly. Less flexible for dynamic behavior modification compared to wrap
maps.wrap
maps provide a ready-made implementation structure focused specifically on wrapping and intercepting standard map operations.wrap
handlers (as shown in examples).deftype
or wrap
map's approach.Clone the repository and run tests using the Clojure CLI:
# Clojure tests
clj -X:test-clj
# ClojureScript tests (requires NodeJS)
clj -M:test-cljs
To run benchmarks:
clj -M:benchmark-clj
clj -M:benchmark-cljs-node
Or add some long form discussoin to the forum post up on Clojureverse: https://clojureverse.org/t/wrap-maps/11338
Copyright © 2025 Jolygon
Distributed under the MIT license. See LICENSE file for details.
Can you improve this documentation? These fine people already did:
John Michael Newman III & John NewmanEdit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close