Introduction to Lenses — The Nested Update Problem
Tutorial
The Problem
Updating a deeply nested field in an immutable data structure requires manually reconstructing every level of nesting: AppConfig { db: DbConfig { host: new_host, ..config.db }, ..config }. For three levels of nesting this is already verbose; for five levels it is unmaintainable. Lenses solve this by encapsulating the path to a field as a first-class value that can be composed, passed, and applied generically. They are fundamental to functional programming with immutable data.
🎯 Learning Outcomes
get (read) and set (write) functionsCode Example
fn update_db_port(config: &AppConfig, new_port: u16) -> AppConfig {
AppConfig {
server: ServerConfig {
db: DbConfig { port: new_port, ..config.server.db.clone() },
..config.server.clone()
},
..config.clone()
}
}Key Differences
lens-rs provide #[derive(Lens)] to auto-generate lenses; OCaml's ppx_lens does the same — both reduce boilerplate.{ r with field = v } updates one level in-place syntactically; Rust's StructName { field: v, ..r } is similar but slightly more verbose.(|>>) in OCaml and .compose() in Rust are equivalent; both produce lenses that traverse multiple levels in one shot.Recoil, Redux's selectors, and game engine component systems all use lens-like patterns.OCaml Approach
OCaml's optics libraries (optics, lens) provide the same abstraction. The Haskell tradition of data-lens influenced both languages. OCaml's record update syntax { r with field = value } handles one level natively. For deeper nesting, lenses are essential. OCaml's functorial approach wraps lenses in modules; the ppx_lens preprocessor generates lenses automatically from type definitions.
Full Source
#![allow(clippy::all)]
// Example 201: The Nested Update Problem — Why Lenses Exist
// === The Problem: Deeply Nested Struct Updates === //
#[derive(Debug, Clone, PartialEq)]
struct DbConfig {
host: String,
port: u16,
name: String,
}
#[derive(Debug, Clone, PartialEq)]
struct ServerConfig {
db: DbConfig,
max_connections: u32,
}
#[derive(Debug, Clone, PartialEq)]
struct AppConfig {
server: ServerConfig,
debug: bool,
version: String,
}
// Approach 1: Manual nested update — clone everything by hand
fn update_db_port_manual(config: &AppConfig, new_port: u16) -> AppConfig {
AppConfig {
server: ServerConfig {
db: DbConfig {
port: new_port,
..config.server.db.clone()
},
..config.server.clone()
},
..config.clone()
}
}
// Approach 2: Helper functions — map at each level
fn map_server(f: impl FnOnce(ServerConfig) -> ServerConfig, config: &AppConfig) -> AppConfig {
AppConfig {
server: f(config.server.clone()),
..config.clone()
}
}
fn map_db(f: impl FnOnce(DbConfig) -> DbConfig, server: ServerConfig) -> ServerConfig {
ServerConfig {
db: f(server.db.clone()),
..server
}
}
fn set_port(port: u16, db: DbConfig) -> DbConfig {
DbConfig { port, ..db }
}
fn update_db_port_helpers(config: &AppConfig, new_port: u16) -> AppConfig {
map_server(|s| map_db(|d| set_port(new_port, d), s), config)
}
// Approach 3: Lenses — composable getters and setters
struct Lens<S, A> {
get: Box<dyn Fn(&S) -> A>,
set: Box<dyn Fn(A, &S) -> S>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
fn new(get: impl Fn(&S) -> A + 'static, set: impl Fn(A, &S) -> S + 'static) -> Self {
Lens {
get: Box::new(get),
set: Box::new(set),
}
}
fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B>
where
A: Clone,
S: Clone,
{
use std::rc::Rc;
let outer_get: Rc<dyn Fn(&S) -> A> = Rc::from(self.get);
let outer_set = self.set;
let inner_get = inner.get;
let inner_set = inner.set;
let og1 = outer_get.clone();
let og2 = outer_get;
Lens {
get: Box::new(move |s| (inner_get)(&(og1)(s))),
set: Box::new(move |b, s| {
let a = (og2)(s);
let new_a = (inner_set)(b, &a);
(outer_set)(new_a, s)
}),
}
}
}
fn server_lens() -> Lens<AppConfig, ServerConfig> {
Lens::new(
|c: &AppConfig| c.server.clone(),
|s: ServerConfig, c: &AppConfig| AppConfig {
server: s,
..c.clone()
},
)
}
fn db_lens() -> Lens<ServerConfig, DbConfig> {
Lens::new(
|s: &ServerConfig| s.db.clone(),
|d: DbConfig, s: &ServerConfig| ServerConfig { db: d, ..s.clone() },
)
}
fn port_lens() -> Lens<DbConfig, u16> {
Lens::new(
|d: &DbConfig| d.port,
|p: u16, d: &DbConfig| DbConfig {
port: p,
..d.clone()
},
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> AppConfig {
AppConfig {
server: ServerConfig {
db: DbConfig {
host: "localhost".into(),
port: 5432,
name: "mydb".into(),
},
max_connections: 100,
},
debug: false,
version: "1.0".into(),
}
}
#[test]
fn test_manual_update() {
let c = update_db_port_manual(&sample_config(), 5433);
assert_eq!(c.server.db.port, 5433);
assert_eq!(c.server.max_connections, 100);
}
#[test]
fn test_helper_update() {
let c = update_db_port_helpers(&sample_config(), 5433);
assert_eq!(c.server.db.port, 5433);
}
#[test]
fn test_lens_update() {
let lens = server_lens().compose(db_lens()).compose(port_lens());
let c = (lens.set)(5433, &sample_config());
assert_eq!((lens.get)(&c), 5433);
assert_eq!(c.server.max_connections, 100);
}
#[test]
fn test_all_equivalent() {
let cfg = sample_config();
let c1 = update_db_port_manual(&cfg, 9999);
let c2 = update_db_port_helpers(&cfg, 9999);
let lens = server_lens().compose(db_lens()).compose(port_lens());
let c3 = (lens.set)(9999, &cfg);
assert_eq!(c1, c2);
assert_eq!(c2, c3);
}
}#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> AppConfig {
AppConfig {
server: ServerConfig {
db: DbConfig {
host: "localhost".into(),
port: 5432,
name: "mydb".into(),
},
max_connections: 100,
},
debug: false,
version: "1.0".into(),
}
}
#[test]
fn test_manual_update() {
let c = update_db_port_manual(&sample_config(), 5433);
assert_eq!(c.server.db.port, 5433);
assert_eq!(c.server.max_connections, 100);
}
#[test]
fn test_helper_update() {
let c = update_db_port_helpers(&sample_config(), 5433);
assert_eq!(c.server.db.port, 5433);
}
#[test]
fn test_lens_update() {
let lens = server_lens().compose(db_lens()).compose(port_lens());
let c = (lens.set)(5433, &sample_config());
assert_eq!((lens.get)(&c), 5433);
assert_eq!(c.server.max_connections, 100);
}
#[test]
fn test_all_equivalent() {
let cfg = sample_config();
let c1 = update_db_port_manual(&cfg, 9999);
let c2 = update_db_port_helpers(&cfg, 9999);
let lens = server_lens().compose(db_lens()).compose(port_lens());
let c3 = (lens.set)(9999, &cfg);
assert_eq!(c1, c2);
assert_eq!(c2, c3);
}
}
Deep Comparison
Comparison: Example 201 — The Nested Update Problem
The Pain: Manual Nested Update
OCaml
let update_db_port config new_port =
{ config with
server = { config.server with
db = { config.server.db with
port = new_port } } }
Rust
fn update_db_port(config: &AppConfig, new_port: u16) -> AppConfig {
AppConfig {
server: ServerConfig {
db: DbConfig { port: new_port, ..config.server.db.clone() },
..config.server.clone()
},
..config.clone()
}
}
The Solution: Lens Type
OCaml
type ('s, 'a) lens = {
get : 's -> 'a;
set : 'a -> 's -> 's;
}
let compose outer inner = {
get = (fun s -> inner.get (outer.get s));
set = (fun a s -> outer.set (inner.set a (outer.get s)) s);
}
Rust
struct Lens<S, A> {
get: Box<dyn Fn(&S) -> A>,
set: Box<dyn Fn(A, &S) -> S>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B>
where A: Clone, S: Clone {
// ... chains get/set through both levels
}
}
Usage Comparison
OCaml
let app_db_port = compose (compose server_lens db_lens) port_lens
let new_config = app_db_port.set 5433 config
Rust
let app_db_port = server_lens().compose(db_lens()).compose(port_lens());
let new_config = (app_db_port.set)(5433, &config);
Exercises
over function that applies f to the field focused by a lens and returns the updated structure.config.server.db.port and increment it.modify_all(lens, f, configs: Vec<Config>) -> Vec<Config> using map + over.