This is a tutorial for Specter, a Clojure(script) library for transforming complex data structures.
I wrote this as an unofficial complement to the Specter documentation. It’s intended for those who’ve maybe heard of Specter, but don’t understand exactly how to use it in their Clojure(Script) projects.
The tutorial consists of a series of worked examples based on a “real-world” application, Factor. Each example presents an increasingly complex data transformation problem, introducing new Specter features as they go:
- Getting a factory’s name
- Getting all factory names
- Changing a factory’s name
- Adding a default name to factories
- Lowercase all factory names
- Make two changes at once
- Delete a factory
- Collecting values
- Abstraction and Reusability
All the examples are based on a central data structure called app-db
:
(def app-db
{:world
{:factories {:1 {:name "My Factory"}
:2 {:name "My Second Factory"}}
:items {:1 {:name "Iron ore"}
:2 {:name "Iron ingot"}}
:recipes {:1 {:name "Smelt iron ore"
:input {:1 1}
:output {:2 1}}}}})
This is a simplified version of Factor’s actual app-db
. It holds all our application state, and needs to be queried and updated in a variety of ways, as we’ll see in the examples below.
In addition to expecting app-db
to be defined, the code examples throughout the tutorial assume that Specter has been required like this:
(require '[com.rpl.specter :as s])
1. Getting a factory’s name
Let’s start simple. We want to write a get-factory-name
function that accepts:
- The current
app-db
. - A factory’s ID (e.g.
:1
).
And returns:
- The name of the factory with the given ID (e.g.
"My Factory"
).
For example:
(get-factory-name app-db :1)
; => "My Factory"
So, how would we implement this function?
Without Specter, get-factory-name
can be written with a get-in
call:
(defn get-factory-name [app-db id]
(get-in app-db [:world :factories id :name]))
(get-factory-name app-db :1)
; => "My Factory"
Simple, right?
We can do the same thing with Specter’s select
function:
(defn get-factory-name [app-db id]
(first (s/select [:world :factories id :name] app-db)))
(get-factory-name app-db :1)
; => "My Factory"
select
works like get-in
, but instead of accepting a path of keys, it accepts a path of navigators. The navigator abstraction is the heart of what makes Specter so powerful.
Some navigators can navigate to multiple values, so the select
function always returns a vector of results. In the preceding example, we only expect a single result, so we used first
to pull only the first value from the select
.
A more conventional way to write this would be with the select-any
macro:
(defn get-factory-name [app-db id]
(s/select-any [:world :factories id :name] app-db))
select-any
works like select
, but it doesn’t return a vector of results. Instead it returns the first selected value, or nil
if no value is selected.
There are also functions select-one
and select-one!
that throw exceptions if there are more than one result — but the additional safety adds performance cost, because navigation can’t be halted when the first result is found. Using select-any
should be preferred unless you need to guarantee that there is at most one match.
Using navigators, we get the concise, expressive power of Clojure’s get-in
, assoc-in
, etc., but generalized to support “navigating through” data structures in arbitrary ways (iteration, transformation, filtering, etc.). Specter has a lot of navigators that you can use to hone in on exactly which values you want to set, even deep within a complex data structure.
In the above get-factory-name
example, our navigators are simple keywords:
:world
:factories
id
:name
(You’ll see some more complex and interesting navigators later in the tutorial — we’re starting simple.)
We can compose navigators together by putting them in a vector:
[:world :factories id :name]
A vector like that is called a path. When such a path is passed to the select
macro, it gets transparently compiled into optimized function calls that can sometimes beat the performance of the hand-written variant!
Syntactic Sugar Navigators
Technically, Specter paths can include three types of navigators that you’ll encounter:
- Objects that implement Specter’s RichNavigator protocol, e.g.
keypath
orALL
. RichNavigators can encompass arbitrary navigation, so you have to read the docstrings of the these navigators to understand what they do. - Plain keywords, e.g.
:world
, navigate to the key in an associated data structure. A shorthand form of(keypath :world)
. - Plain functions, e.g.
empty?
, can be used to filter the selected values. A shorthand form of(pred empty?)
.
The latter two are pure syntactic sugar. I often use the sugar instead of writing the more verbose and explicit form.
Composing Paths
Paths are themselves navigators. A path can be used anywhere a navigator can be used and vice-versa:
[:world [:factories id] :name]
The equivalence of paths and navigators makes it possible to easily define reusable navigators, for example:
(def WORLD-FACTORIES [:world :factories])
(s/select-any [WORLD-FACTORIES id :name] app-db)
We can use the path
macro to pre-compile our paths for performance improvements:
(def WORLD-FACTORIES (s/path :world :factories))
(s/select-any [WORLD-FACTORIES id :name] app-db)
select
, select-any
and other Specter macros automatically wrap their first parameter in a path
, which is why
we don’t need to do this:
(s/select-any (s/path WORLD-FACTORIES id :name) app-db)
See Abstraction and Reusability for a more in-depth discussion of navigator reusability.
2. Getting all factory names
Next, let’s explore how Specter helps us select more than one value at once.
Suppose we want to write a get-factory-names
function that accepts:
- The current
app-db
.
And returns:
- A sequence of the names of all the factories.
For example:
(get-factory-names app-db)
; => ("My Factory" "My Second Factory")
Without Specter, we could implement get-factory-names
like so:
(defn get-factory-names [app-db]
(-> app-db
(get-in [:world :factories])
(vals)
(->> (map :name))))
(get-factory-names app-db)
; => ("My Factory" "My Second Factory")
To write the Specter equivalent of this, we need to understand how to navigate over all the values in a map, instead of honing in on a certain key. To figure out what navigator to use, we’ll check the Specter cheatsheet. It’s is organized according to the type of the currently navigated value, so we should look in the section for Map navigators.
We look through the list and notice the MAP-VALS
navigator. It navigates to all the values in a map. Sounds promising!
Let’s write the Specter version of get-factory-names
using MAP-VALS
:
(defn get-factory-names [app-db]
(s/select
[:world :factories s/MAP-VALS :name]
app-db))
(get-factory-names app-db)
; => ["My Factory" "My Second Factory"]
You may wonder why MAP-VALS
is capitalized like it is. Some navigators accept parameters, like (keypath & keys)
, and the Specter convention is that such navigators are lowercase, just like functions, and they have to be called like functions when used. Navigators that don’t take parameters, like MAP-VALS
, are named with ALL-CAPS
and aren’t called when used (no ()
around them).
Same Structure, Same Intent
If you squint, the Specter and non-Specter versions are structurally similar. Both have three stages of selecting data:
- Navigating to the
:factories
map. - Navigating to all the values of the map.
- Navigating to the
:name
of each of the values.
The Specter version has an interesting property, though: It’s awfully similar to the get-factory-name
function from the first example!
All we had to do is replace id
with MAP-VALS
, and switch the select-any
to select
so we return multiple values:
; example 1
(defn get-factory-name [app-db id]
(s/select-any
[:world :factories id :name]
app-db))
; example 2
(defn get-factory-names [app-db]
(s/select
[:world :factories s/MAP-VALS :name]
app-db))
This highlights some of the expressive power of Specter’s navigators. A small, encapsulated change in intent corresponds to an equally small, equally encapsulated change in code.
In contrast, the non-Specter version was almost completely rewritten:
; example 1
(defn get-factory-name [app-db id]
(get-in app-db [:world :factories id :name]))
; example 2
(defn get-factory-names [app-db]
(-> app-db
(get-in [:world :factories])
(vals)
(->> (map :name))))
The logic is similar, but the actual forms are significantly different. Not only that, the non-Specter version may hide bugs more easily, especially for less experienced Clojurers. It’s easier to write bugs and harder to notice them when reading later:
; bad
(defn get-factory-names [app-db]
(-> app-db
(get-in [:world :factories])
(vals)
(map :name)))
Use Clojure enough and you notice things like that right away… most of the time.
I’m reminded of Hoare:
There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.
Specter, used responsibly, helps us move towards the former.
Navigating Sequences
One thing that can trip you up when working with navigators is the difference between navigating “to a sequence” and navigating “to the values in a sequence.”
This can be confusing, or I found it confusing anyway. But understanding the difference is essential to using navigation effectively.
For example, suppose we have this data structure:
(def x {:foo [-1 0 1]})
And we want to extract the value at :foo
. We’d write:
(s/select-any
[:foo]
x)
; => [-1 0 1]
Cool! We’re using select-any
and it’s returning a single value, which is a vector.
Now, suppose we decide we only want to see the positive values.
We look at the cheatsheet and spot the pred
navigator, which we can use to filter out any values that don’t match a predicate! Sounds good, right?
We eagerly update our path:
(s/select-any
[:foo (s/pred pos?)]
x)
; => nil
Oh no! We expected to get [1]
but got nil
instead. What happens if we change the select-any
to select
?
(s/select
[:foo (s/pred pos?)]
x)
; => []
Still not right!
Can you spot the issue?
:foo
navigates us to the value[-1 0 1]
(s/pred pos?)
calls(pos? [-1 0 1])
and returns false.- Because nothing matched the predicate, no results are returned.
Of course, we want to call pos?
on the elements of the vec [-1 0 1]
, not the vec itself. Step 2 is wrong!
How can we fix it?
Specter has a navigator ALL
that navigates from a sequence to the elements of that sequence. That’s what we need to use here:
(s/select
[:foo s/ALL (s/pred pos?)]
x)
; => [1]
Success!
By the way, instead of using ALL
and pred
, we could express the same thing with another navigator, filterer
, which accepts a sequence and navigates to a new sequence with only the values that match the given predicate.
Conceptually, if the prior example was “selecting some of the values of a sequence”, then using filterer
is more like “selecting a filtered sequence.”
The end result is the same — most of the time — but understanding the difference is still important. When we get to other Specter functions like transform
(which we’ll see in later examples), you’ll see a situation these can produce different results!
Since filterer
navigates to a sequence, not to the elements in the sequence, we would have to switch back to select-any
to get the same results:
(s/select-any
[:foo (s/filterer pos?)]
x)
; => [1]
Or, we could still use select
if we put ALL
after the filterer
:
(s/select
[:foo
(s/filterer pos?)
s/ALL]
x)
; => [1]
Instead of “selecting a filtered sequence”, now we’re “selecting all the values of a filtered sequence!” Another slightly different navigation that, in this case, produces the same result.
The difference between navigating to a sequence and navigating to the values in a sequence can be a tricky thing to grok. But once it clicks, navigating data structures will feel like a piece of cake!
3. Changing a factory’s name
Now let’s take a step away from select
and consider one of Specter’s other functions for manipulating data structures: setval
.
Suppose instead of getting a factory’s :name
, we want to write an event handler that can update the :name
to a new value.
We want a function that accepts:
- The current
app-db
. - A factory’s ID (e.g.
:1
). - A string to use as the new name (e.g.
"Foo"
).
And returns:
- An updated
app-db
, where the given factory’s name is replaced with the given name.
For example:
(set-factory-name app-db :1 "Foo")
; => {:world
; {:factories {:1 {:name "Foo"} ;; UPDATED!
; :2 {:name "My Second Factory"}}
; ...}}
Without Specter, we can implement set-factory-name
using assoc-in
:
(defn set-factory-name [app-db id name]
(assoc-in app-db [:world :factories id :name] name))
To solve this problem with Specter, we can use setval
, which is Specter’s generalized assoc-in
equivalent.
Like select
, setval
accepts a path of navigators. But instead of returning the navigated values, it replaces them with a new value, and returns the whole data structure with new values in place.
We can rewrite the set-factory-name
function to use setval
:
(defn set-factory-name [app-db id name]
(s/setval [:world :factories id :name] name app-db))
Note the path
we give to setval
is exactly the same path we gave to select
in exercise one:
[:world :factories id :name]
Again, this highlights the reusability of the navigator abstraction. The same navigators can be used for both selecting and transforming data.
Also like select
, (and unlike assoc-in
) setval
has the ability to navigate to (and set) multiple values at once! Let’s see how it works in the next section:
4. Adding a default name to factories
Suppose some of our factories have no :name
, and we want to give them all a name of "Unnamed Factory"
. But, we don’t want to overwrite the name if the factory already has one.
For example:
(def app-db
{:world
{:factories {:1 {:name nil}
:2 {:name "My Second Factory"}}}})
(add-default-name-to-factories app-db "Unnamed Factory")
; => {:world {:factories {:1 {:name "Unnamed Factory"}
; :2 {:name "My Second Factory"}}}}
We can use setval
for this, too.
To better understand how our solution works, let’s build the solution in two stages:
- First, we’ll solve a simpler problem: setting all factories’ names to
"Unnamed Factory"
. - Then, we’ll add logic to avoid overwriting existing names.
For the first stage, we just have to write a path that navigates to the names of all the factories. Luckily, we already wrote that path back in example 2! It went like this:
[:world :factories s/MAP-VALS :name]
In example 2, we used the path to select all the names from the database. Now, we can pass the very same path to setval
to replace all the names at once:
(defn add-default-name-to-factories [app-db name]
(s/setval [:world :factories s/MAP-VALS :name]
name
app-db))
So far, so good. But, if we were to run this now, it would clobber all the preexisting factory names, which we don’t want:
(add-default-name-to-factories app-db "Unnamed Factory")
; => {:world {:factories {:1 {:name "Unnamed Factory"}
; :2 {:name "Unnamed Factory"}}}}
It’s time to fix our function so it only replaces nil
values.
How?
The setval
function replaces all the values that it navigates to. So, we just want to “somehow” stop navigating to non-nil
names.
The simplest way is to add a nil?
function to the path (remember that a plain function in the a path is used as a filter — we could also use (pred nil?)
and it would have the same effect):
(defn add-default-name-to-factories [app-db name]
(s/setval [:world :factories s/MAP-VALS :name nil?]
name
app-db))
Now if we run our function, it works properly! Only the nil name was replaced:
(add-default-name-to-factories app-db "Unnamed Factory")
; => {:world {:factories {:1 {:name "Unnamed Factory"}
; :2 {:name "My Second Factory"}}}}
5. Lowercase all factory names
So, that’s setval
, which is the Specter analogue of assoc-in
.
Does Specter have an analogue of update
and update-in
?
It does, and it’s called transform
. (And it has a big brother, multi-transform
, that I’ll introduce later.)
The transform
function works just like setval
except instead of being passed a constant value to assign, it’s passed a function called a transformer that accepts the navigated value and returns some transformed data. The navigated values are replaced with their transformed data and then the whole data structure is returned.
Suppose, for example, we want to change the names of all our factories to make them lowercase. This is a perfect use-case for transform
:
(lowercase-factory-names app-db)
; => {:world {:factories {:1 {:name "my factory"}
; :2 {:name "my second factory"}}
; ...}}
In Specter terms, we want to navigate to all of the factory names, and transform them with the clojure.string/lower-case
function.
You could probably write this yourself at this point:
(defn lowercase-factory-names [app-db]
(s/transform [:world :factories s/MAP-VALS :name string?]
clojure.string/lower-case
app-db))
We included a string?
at the end of the path in order to skip transforming any non-string values that might cause lower-case
to throw an exception.
The lowercase-factory-names
function, defined with Specter, is concerned with expressing intent: we don’t say “how” to modify the data structure mechanically, we just say what we want to change.
Let’s compare that with a non-Specter implementation of the same function:
(defn lowercase-factory-names [app-db]
(update-in app-db [:world :factories]
(fn [factories]
(->> factories
(map (fn [[id factory]]
[id (update factory :name
#(if (string? %)
(clojure.string/lower-case %)
%))]))
(into {})))))
The non-Specter version is concerned with mechanics: threading values through a variety of different nested functions to achieve the same effect.
lowercase-factory-names
is an example of a function that, should I want to solve it without Specter, I would break into smaller functions.
Something like this, for example, seems more readable to me:
(defn map-vals [f m]
(into {} (map (fn [[k v]] [k (f v)]) m)))
(defn maybe-lowercase [s]
(if (string? s) (clojure.string/lower-case s) s))
(defn factory-name->lowercase [factory]
(update factory :name maybe-lowercase))
(defn lowercase-factory-names [app-db]
(update-in app-db [:world :factories]
#(map-vals factory-name->lowercase %)))
Encapsulating logic is important!
With Specter, we encapsulate logic by composing navigators together instead of composing functions (where possible).
An example of a better-modularized Specter version:
(def MAP-FACTORIES (s/path :world :factories s/MAP-VALS))
(def FACTORY-NAME (s/path :name string?))
(defn lowercase-factory-names [app-db]
(s/transform
[MAP-FACTORIES FACTORY-NAME]
clojure.string/lower-case
app-db))
See Abstraction and Reusability for more details.
6. Make two changes at once
Suppose we want to add two values to every factory — a :desc
and an :author
— but only if there are no existing values for those keys.
(add-desc-and-author app-db)
; => {:world {:factories {:1 {:name "My Factory"
; :desc "No description"
; :author "Unknown"}}
; :2 {:name "My Second Factory"
; :desc "No description"
; :author "Unknown"}
; ...}}}
There are a few ways to approach this.
One option is to thread the database through two setval
calls:
(defn add-desc-and-author [app-db]
(->> app-db
(s/setval [:world :factories s/MAP-VALS :desc nil?]
"No description")
(s/setval [:world :factories s/MAP-VALS :author nil?]
"Unknown")))
This works, but we’re iterating over all the factories twice, which is unnecessary work.
Our inner optimizer tells us, “We only need to iterate over the factories once! We can use transform
— just navigate to each factory, and transform the factory by adding the :author
and :desc
fields at once!
(defn add-desc-and-author [app-db]
(s/transform
[:world :factories s/MAP-VALS]
#(cond-> %
(nil? (:desc %)) (assoc :desc "No description")
(nil? (:author %)) (assoc :author "Unknown"))
app-db))
This is nice. Our inner optimizer is happy. But it’s come at the cost of writing a somewhat-complicated transformer function. The result feels a little less declarative, a little less obviously correct.
Is there a better way?
It turns out, we can write this in a way that’s both efficient and declarative using the many-armed multi-transform
function!
multi-transform
allows you to perform multiple transform
(or setval
) operations at once. They can share navigators to avoid redundant computation, but also branch out into their own navigations using the multi-path
navigator:
; Use multi-path for branching navigation!
(s/multi-path
[(s/keypath :desc)
...]
[(s/keypath :author)
...])
Unlike transform
, multi-transform
doesn’t accept a transformer function as a parameter. After all, we might want to transform each of the multi-path destinations in a different way. A single transformer function isn’t expressive enough for that.
Instead, when using multi-transform
you must use the special terminal
and terminal-val
navigators to “terminate” the branches of your navigation and either apply a transformer or set a value.
We use the terminal
navigator to apply a transformer function (a la transform
):
(s/terminal clojure.string/lower-case)
Or we use the terminal-val
navigator to set a value (a la setval
):
(s/terminal-val "Foo")
So, to implement add-desc-and-author
we can write:
(defn add-desc-and-author [app-db]
(s/multi-transform
[:world :factories s/MAP-VALS
(s/multi-path
[:desc nil? (s/terminal-val "No description")]
[:author nil? (s/terminal-val "Unknown")])]
app-db))
Neat! We’re only iterating over the factories once, because the MAP-VALS
navigator is outside the multi-path
. But we didn’t have to use a handwritten transformer, so our code is still nice and declarative.
The multi-path
navigator works especially well with multi-transform
, but it can be useful with other Specter macros (like setval
and transform
) too.
For example, if we wanted :desc
and :author
to both default to the same value, say "Unknown"
, we can use multi-path
without multi-transform
:
(defn add-desc-and-author [app-db]
(s/setval [:world :factories s/MAP-VALS
(s/multi-path [:desc nil?]
[:author nil?])]
"Unknown"
app-db))
7. Delete a factory
Suppose we want to delete a factory from the database:
(delete-factory app-db :1)
; => {:world {:factories {:2 {:name "My Second Factory"}}
; ...}}
Without Specter, we’d do an update-in
combined with dissoc
:
(defn delete-factory [app-db id]
(update-in app-db [:world :factories] dissoc id))
At first, it’s not obvious how to do this in Specter.
We could lean on transform
:
(defn delete-factory [app-db id]
(s/transform [:world :factories] #(dissoc % id) app-db))
You may have noticed a pattern, though: Specter works best when we express our logic in navigators. Whenever we have logic in our transformer function, we should ask ourselves, “Is there some way to express this logic with navigators instead?”
So, is there a way to express the logic of “deleting” a value without using a transformer?
Turns out, there is! Specter provides a special value NONE
, which represents the absence of a value. If you set a navigated value to NONE
, Specter deletes it!
The NONE
value can be used anywhere Specter is expecting a value: setval
, transform
, multi-transform
, etc. all support it.
In this case, we can rewrite our function to use setval
. We just have to navigate to the factory and set the value to NONE
:
(defn delete-factory [app-db id]
(s/setval [:world :factories id] s/NONE app-db))
Cleaning up empty sequences
Sometimes when we delete data with NONE
, it leaves us with empty data structures: maps with no keys or sequences with no values.
If we ask, Specter can clean those up for us while it’s processing. This is called compacting the empty data structures. Let’s look at a slightly different example of deleting data to see how compaction could be useful:
Suppose we want to represent the edges of a Directed Graph using a Clojure data structure.
We decide to write the edges in a nested map, where the outer key is the ID of the edge’s starting node, and the inner key is the ID of the edge’s ending node. Each edge is associated with a descriptive string value.
(def edges {:a {:b "edge from a -> b"
:c "edge from a -> c"}
:b {:d "edge from b -> d"}})
With this structure, we can get the description for the edge from node :a
to node :b
using:
(get-in edges [:a :b])
; => "edge from a -> b"
(get-in edges [:b :d])
; => "edge from b -> d"
If the edge doesn’t exist, we get nil
:
(get-in edges [:a :d])
; => nil
Now, suppose we want to remove the edge from :a
to :b
using Specter. We write:
(s/setval [:a :b] s/NONE edges)
; => {:a {:c "edge from a -> c"}
; :b {:d "edge from b -> d"}}
So far, so good. But what if we try to remove the edge from :b
to :d
:
(s/setval [:b :d] s/NONE edges)
; => {:a {:b "edge from a -> b"
; :c "edge from a -> c"}
; :b {}}
It works, but we’re left with an empty map ({}
) for the key :b
. We’d like to avoid that! If a node has no edges originating from it, we want to remove its key from the outer map. We want the :b
key to just disappear:
{:a {:b "edge from a -> b"
:c "edge from a -> c"}}
This is where the compact
navigator comes in.
compact
accepts a sub-path, and normally it just navigates to the sub-path — effectively, it’s a no-op — except in the following situation:
- The navigated value was set to
NONE
. - After the navigated value was deleted, one or more of the data structures
that were navigated to within the
compact
path were empty, up to and including the navigated value at the point wherecompact
is called.
If both those are true, then the compact
navigator will clean up the empty data structures automatically!
So we can write our setval
like this:
(s/setval [(s/compact :b :d)] s/NONE edges)
; => {:a {:b "edge from a -> b"
; :c "edge from a -> c"}}
The compact
navigator is powerful but can be tricky to wrap your head around because it compacts the currently navigated value when compact
is called, not the values navigated to in the subpath.
In the above example, compact
is the first navigator which means we’re compacting the whole edges
object. If all the edges are gone, the whole object is eliminated and Specter actually returns the NONE
value back to us:
(s/setval [(s/compact :a :b)]
s/NONE
{:a {:b "edge from a -> b"}})
; => s/NONE
If we want to keep an empty map even if there are no edges, we need to move the first navigator out of the compact
:
(s/setval [:a (s/compact :b)]
s/NONE
{:a {:b "edge from a -> b"}})
; => {}
It can be difficult to use compact
in a disciplined way — it’s one of the parts of Specter that, IMO, can obscure intent more than it reveals. Nevertheless, there are times when it can be very useful.
My recommendation is to use with caution, and where possible, encapsulate it with custom navigators to express your intent more clearly.
8. Collecting values
I’m going to introduce one more advanced-but-useful Specter feature: collecting values.
Collected values sorta like capture groups in a regex: any collected values are returned alongside any selected values.
Or, in the case of transform
and multi-transform
, the collected values are provided as additional inputs to the transformer function(s).
Suppose, for example, we want to define a function to add a new input item to a recipe. The item is referenced by its item ID.
We can do this with transform
:
(defn add-recipe-input [app-db recipe-id item-id quantity]
(s/transform [:world :recipes recipe-id :input item-id]
(partial + quantity)
app-db))
(add-recipe-input app-db :1 :2 123)
; => {:world
; {:recipes {:1 {:name "Smelt iron ore"
; :input {:1 1
; :2 123} <=== added this!
; :output {:2 1}}}}}
So far, simple enough.
But now let’s say we don’t know the ID of the item we want to add. We just know the name of the item, e.g. the :2
item is named "Iron ingot"
. Well, this is a great opportunity for the collect-one
navigator. Let’s use it:
(defn add-recipe-input [app-db recipe-id item-name quantity]
(s/transform
[:world
(s/collect-one
:items s/ALL
(s/selected? s/LAST :name (s/pred= item-name))
s/FIRST)
:recipes recipe-id :input]
(fn [item-id input] (update input item-id + quantity))
app-db))
(add-recipe-input app-db :1 "Iron ingot" 123)
; => {:world
; {:recipes {:1 {:name "Smelt iron ore"
; :input {:1 1
; :2 123} <=== added this!
; :output {:2 1}}}}}
This function looks much like the last one, but instead of accepting an item ID, it accepts a name. In the collect-one
path, we navigate to the ID of the item with matching :name
. Then, we can use the collected ID in our transformer function — the item-id
parameter is the collected value.
Collecting values is an extremely “power user” feature of Specter. It might seem egregious to try and shove all this logic into navigators, but there are times when it’s super useful too!
My tip would be to start out without using Specter’s value collection. It’s complicated and can interfere with learning. Once you get more comfortable with Specter and the benefits of encapsulating logic in navigators, the value of collection will become very clear, and you can come back to collect
et. al. with a much better basis for understanding.
For now, just be aware that collecting values is possible, and let’s move on to the last section!
9. Abstraction and Reusability
Hopefully by this point we have a good understanding of how Specter works.
We’ve seen that we can use navigators to express our intent declaratively instead of writing the mechanical steps to transform a data structure. And we’ve encountered a bunch of built-in Specter features that help us with that.
Now I want to delve into writing custom navigators so we can express our code’s intent even better: Instead of declaring our intent in terms of Specter’s navigators, we can declare intent using our application’s domain language.
The toy examples we’ve seen so far in this tutorial don’t follow best practices of encapsulating navigators.
When using Specter in an application, I strongly recommend defining custom navigators for any remotely complex/reusable path. Custom navigators make the intent of our application code that much clearer.
For example, we could define a reusable (recipe id)
navigator that encapsulates the idea of “navigate to a specific recipe”:
(defn recipe [id] (s/path :world :recipes id))
Remember, custom navigators are usually declared using the path
macro for performance reasons, but this could also be written:
(defn recipe [id] [:world :recipes id])
The cool thing is we can use this same navigator if we want to select the value, update the value, compose with other navigators, and so on.
Get the recipe:
(s/select-any (recipe :1) db)
Transform the recipe:
(s/transform (recipe :1) #(assoc % :name "foo") db)
Custom navigators can be composed together to create more complex and useful navigations:
(def WORLD (s/path :world))
(def RECIPE-MAP (s/path :recipes))
(def NAME (s/path :name))
(defn recipe [id] (s/path WORLD RECIPE-MAP id))
(defn recipe-name [id] (s/path (recipe id) NAME))
Then the recipe-name
navigator can be used to update/select recipe names directly:
(s/select-any (recipe-name :1) db)
(s/setval (recipe-name :1) "foo" db)
Naming Custom Navigators
As a matter of personal taste, I like custom navigator names to give a hint as to the type of value they’re navigating to. I follow these conventions:
- All navigator names should be nouns — where the noun is the value being navigated to.
- If the name is plural, e.g.
RECIPES
, it can navigate to zero, one or more values. - If the name is singular, e.g.
RECIPE
, it must navigate to exactly zero or one values. - If the value being mapped to is a data structure itself, the name should include the data structure type.
So, for example:
; Navigates to the map of recipes, if it exists
; Singular because there's only one recipe map
(def RECIPE-MAP (s/path :recipes))
; navigates to each recipe in the map
; plural because it navigates to multiple values
(def RECIPES (s/path RECIPE-MAP s/MAP-VALS))
; navigates to each ID in the map
; plural because it navigates to multiple values
(def RECIPE-IDS (s/path RECIPE-MAP s/MAP-KEYS))
In Factor’s codebase, custom navigators are placed in the factor.navs
namespace:
https://github.com/luketurner/factor/blob/master/src/main/factor/navs.cljs
Take a look! By now, you should have all the Specter knowledge to understand pretty much the whole file.
The pattern of putting all navigators in a single namespace works well for small/medium-size apps. Another option would be to colocate the navigator definitions with the schema definitions of the data structures being navigated.
La fin
That’s all, folks!
If you have feedback, suggestions or corrections, send me an email.
Specter reference documentation:
- Cheatsheet: https://github.com/redplanetlabs/specter/wiki/Cheat-Sheet
- List of macros: https://github.com/redplanetlabs/specter/wiki/List-of-Macros
- List of navigators: https://github.com/redplanetlabs/specter/wiki/List-of-Navigators