~ / track E / deep theory

Lenses and optics

Advanced

A lens is a first-class "getter + setter" pair, packaged so you can compose them. Once you can compose lenses, deeply nested-data updates become one line of declarative code instead of a tower of update-in / assoc-in calls. The broader family — lenses, prisms, traversals, isomorphisms, called optics — generalizes this from "one focus" to "many possible foci" or "this slot might not be there at all."

The problem optics solve

In plain Clojure, deep update goes through path-based helpers:

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

That's fine for a single fixed path. But what if you want to apply the upper-case to every address regardless of count? Or only to the primary? You start writing manual loops. Optics are the abstraction that lets you name the focus once and reuse it.

A naive lens

A lens for a key :k is just [(fn get [m] (m k)) (fn set [m v] (assoc m k v))]. Composition is "do the outer one, then the inner one on the result." Two helper functions:

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

A tiny example:

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

customer-name is one named "path." Updating it is the same shape regardless of how deep it is.

Specter: the production-grade library

In real Clojure code you reach for com.rpl/specter, which gives you a much richer vocabulary — including traversals that focus on many values at once. Sketch (won't run in this SCI REPL):

(require '[com.rpl.specter :as sp])

;; upper-case every city in every address
(sp/transform [:customer :addresses sp/ALL :city]
              clojure.string/upper-case
              order)

;; only the primary address's city
(sp/transform [:customer :addresses sp/ALL #(:primary? %) :city]
              clojure.string/upper-case
              order)

The "path" is data: [:customer :addresses sp/ALL :city]. sp/ALL is the traversal that says "every element of this collection." Combine and reuse freely.

The optics zoo (and why)

  • Lens — exactly one focus that always exists (a key in a map).
  • Prism — at most one focus, may not exist (a specific case of a sum type — e.g. "the :left of an Either").
  • Traversal — zero or more foci (every element of a list).
  • Isomorphism — two equivalent representations of the same data.
  • Fold — read-only "many foci," useful for aggregation.

These compose with each other. A "lens then traversal then prism" still behaves like an optic; the laws — round-tripping a getter then a setter behaves predictably — are preserved by composition.

When to reach for optics

  • Nested-data update touches multiple slots in a single transformation.
  • The path itself wants to be passed around — building it dynamically, storing it, reusing across functions.
  • You need to focus conditionally ("every order whose status is paid") — that's a prism or filtered traversal.

For simple assoc-in / update-in, plain Clojure is shorter. Optics earn their keep when paths get wide or conditional.

Check yourself

? quiz

In what situation does a traversal pay off over a plain `update-in` path?

Exercise

Sketch (in pseudocode or Specter form, if you know it) a single path that:

  1. dives into :customer :addresses,
  2. picks only the addresses whose :primary? is true,
  3. upper-cases the :city.

If you don't have Specter at hand, write the equivalent plain Clojure using update-in + mapv + map and reflect on which is more declarative.

 status: new