Lens — Nested Updates
Updating deeply nested properties immutably in standard TypeScript requires nested object spread
syntax ({ ...a, b: { ...a.b, c: ... } }), which becomes verbose and error-prone.
Lens<S, A> models a bidirectional path to a required property A inside a parent structure S,
enabling immutable reads, writes, and modifications with a single reusable optic.
The problem with deep nesting and object spreads
Section titled “The problem with deep nesting and object spreads”Consider an application configuration that manages database connections and connection pools:
If we need to adjust the maximum pool size, the most direct approach is mutation:
While simple, this mutation modifies the object in place. If another part of our application holds a
reference to config, or if a state management system (such as React) relies on reference changes
to trigger updates, this silent modification bypasses those mechanisms. It also makes tracking down
when and where a value changed exceptionally difficult, as the change leaves no trace in the call
stack.
To update the configuration immutably using native JavaScript, we must copy every level of nesting:
This spread pattern is syntactically heavy, error-prone, and tightly coupled to the exact shape of
our data. If we rename database or add another layer, we have to rewrite every spread chain
throughout our codebase.
The shift to first-class paths
Section titled “The shift to first-class paths”A Lens<S, A> separates the description of where a value lives from what we do with it. A lens
is defined by two pure functions:
- A getter to extract the focused value
Afrom the outer structureS. - A setter to return a new outer structure
Swith the focused valueAreplaced.
flowchart TD
S["Outer Structure (S)"] -- "get" --> A["Focused Value (A)"]
A -- "set(newValue)" --> S2["New Outer Structure (S)"]
By defining this path once as a standalone variable, we can reuse it to read, write, and modify the target field across our application.
Creating lenses
Section titled “Creating lenses”The simplest way to create a lens is with Lens.from.property, which focuses on a single property
of an object. The double-parenthesis syntax (prop<Source>()("key")) allows TypeScript to
autocomplete keys and enforce type safety:
For custom paths — such as navigating a non-standard data structure or mapping values on the fly —
we can define a lens manually using Lens.from.accessors:
Reading and writing deep values
Section titled “Reading and writing deep values”Once we have a lens, we can use Lens.get, Lens.set, and Lens.modify to interact with our
structure. These operations are curried and accept the data structure as their last argument, making
them perfectly suited for pipe:
Composing paths for deep updates
Section titled “Composing paths for deep updates”Lenses are truly powerful because they compose. We can combine multiple shallow lenses into a single
deep lens using Lens.andThen.
This composition lets us build complex paths out of simple, reusable building blocks:
The original config object remains unchanged. nextConfig is a new object where only the modified
path references have been updated; unchanged subtrees are preserved by reference.
Reaching fields that might be absent
Section titled “Reaching fields that might be absent”A standard Lens assumes that the path is guaranteed to exist. If a property in our path is
optional (e.g. host?: string) or we want to focus on an element of an array that might be empty,
we must transition from a Lens to an Optional.
We can compose a Lens with an Optional path using Lens.andThenOptional or convert a lens
entirely with Lens.toOptional:
See the Optional guide for details on navigating optional paths.
Problems it solves
Section titled “Problems it solves”- Deep immutable updates without spread boilerplate: In React, Redux, or Zustand state trees,
modifying nested properties three or four levels deep requires multiple levels of spread operators
(
{ ...state, user: { ...state.user, settings: { ... } } }).Lensencapsulates bidirectional get/set operations, enabling single-line immutable updates at any depth. - Decoupling child UI components with sub-model focusing: Passing an entire global state tree to
a child form component couples it to the root schema. Passing a focused
Lens<State, Address>allows the child component to read and update only its specific sub-model while remaining completely agnostic of the parent container structure. - Atomic property modifications (
Lens.modify): Applying pure transformation functions directly to nested fields (such as incrementing counters, trimming strings, or appending items to nested lists) viaLens.modifywithout extracting the value first. - Reusable bidirectional property bindings in forms: In form controllers and settings editors,
field components need to both read nested values for rendering and immutably write back user
inputs on change.
Lensturns nested data paths into first-class values that can be passed directly to form inputs, validation routines, and persistence layers. - Composable data path navigation: Individual lenses targeting sub-models can be combined using
Lens.andThen, allowing complex domain access paths to be assembled dynamically from small, modular path definitions.