Lens — Nested Updates
233 B minified + gzipped
Updating deeply nested properties immutably in standard TypeScript requires nested object spread
syntax ({ ...a, b: { ...a.b, c: ... } }). As data structures grow, spread chains become verbose,
difficult to scan, and prone to silent reference errors.
Lens<S, A> models a bidirectional path to a guaranteed property A inside an outer structure S.
It provides a reusable, composable path descriptor to read, overwrite, and immutably modify values
at any level of nesting.
The problem with nested object spreads
Section titled “The problem with nested object spreads”Consider an application configuration that manages database connections and pool limits:
Mutating deep values directly breaks immutability and bypasses change detection:
To update the configuration immutably with native JavaScript, every intermediate level must be copied manually:
This pattern has three concrete failure modes:
- Spread fatigue: Each additional level of nesting adds another nested object literal. A path four levels deep requires four spread operations.
- Key loss and structural bugs: Omitting a single spread operator silently discards sibling keys at that layer. TypeScript cannot always catch a missing spread if the inner shape happens to satisfy an optional or widened type.
- Tight coupling: The update logic hardcodes the exact schema path at the call site. If
databaseis moved under aninfrastructureblock, every manual spread chain across the codebase must be rewritten.
Type Structure & Mental Model
Section titled “Type Structure & Mental Model”A Lens<S, A> separates the definition of where a value lives from the operations performed on it:
A lens consists of two pure functions:
get: Extracts the focused valueAfrom the outer structureS.set: Accepts a new valueAand an outer structureS, returning an updated outer structureSwith the focused value replaced.
flowchart TB
subgraph Read ["Focused Extraction"]
S["Outer Structure (S)"] -->|"Lens.get"| A["Focused Value (A)"]
end
subgraph Write ["Focused Replacement"]
S2["Outer Structure (S)"] & Val["New Value (A)"] -->|"Lens.set(value)(structure)"| SUpdated["Updated Structure (S)"]
end
A ~~~ S2
Because a lens holds both functions, it acts as a bidirectional channel. Defining a lens once turns an access path into a first-class value that can be stored, passed to child components, and composed with other lenses.
Creating Lenses
Section titled “Creating Lenses”The primary constructor is Lens.from.property, which focuses on an object key. The
double-parenthesis invocation (Lens.from.property<Structure>()("key")) allows TypeScript to
autocomplete valid property keys and infer the target value type:
Custom accessors
Section titled “Custom accessors”For data structures that are not plain keyed objects — such as tuples, positional coordinates, or
custom records — Lens.from.accessors constructs a lens from custom getter and setter functions:
Reading and Writing Deep Values
Section titled “Reading and Writing Deep Values”Lens.get, Lens.set, and Lens.modify are curried and accept the target structure as their final
argument, allowing clean integration with pipe:
Reference preservation with modify
Section titled “Reference preservation with modify”Lens.modify inspects the output of the transformation function using Object.is. If the mapper
returns the exact same value that was already present, Lens.modify returns the original structure
reference unchanged. This avoids unnecessary object allocations and prevents redundant re-renders in
UI frameworks that rely on referential equality:
Composing Paths with andThen
Section titled “Composing Paths with andThen”Individual lenses compose into deep paths using Lens.andThen. The resulting composite lens behaves
like any standard lens, reading and updating through the entire chain in a single step:
updatedConfig contains the new limit. Intermediate objects along the path (database and pool)
are copied immutably, while untouched siblings remain referentially equal to their counterparts in
config.
Conversions and Optional Paths
Section titled “Conversions and Optional Paths”A Lens requires that the target field is guaranteed to exist. When a path crosses an optional or
nullable property (e.g. billingAddress?: Address), the path transitions from a guaranteed lens to
an Optional.
Lens.andThenOptional composes a Lens with an Optional segment, producing an Optional.
Alternatively, Lens.to.Optional widens a Lens directly into an Optional whose getter always
returns Some:
See the Optional guide for details on navigating and modifying nullable paths.
Pipeline Composition
Section titled “Pipeline Composition”Here is an end-to-end e-commerce order fulfillment pipeline. It receives an order payload, normalizes customer shipping details, recalculates package dimensions, and increments a processing revision counter. Using lenses, each step operates directly on its focused sub-model without coupling helper functions to the overall order schema:
Problems it solves
Section titled “Problems it solves”- Deep updates in nested trees require cascades of spread operators that drift rightward and obscure
business intent.
Lensencapsulates path navigation, enabling single-line reads, writes, and updates at any level of nesting. - Passing entire root state objects to child components or domain helpers tightly couples those
sub-routines to the global schema. Passing a focused
Lens<State, SubModel>allows sub-routines to read and mutate their specific slice in complete isolation. - Unintentional object allocations in state managers trigger cascading UI re-renders.
Lens.modifychecksObject.isbefore applying writes, returning the original object reference if the target value did not change. - Refactoring schema layouts usually requires searching and rewriting manual spread patterns across dozens of files. Lenses centralize access paths into standalone values that can be updated in a single location.
- Form inputs and bidirectional controls require both an extractor to display current state and an
updater to persist changes.
Lensprovides a unified pair of pure functions that can be passed directly into form controllers and settings widgets.