Practical Lens — Deeply Nested Config Update
Tutorial
The Problem
This example returns to the motivating problem from the lens introduction (example 201) and solves it completely with the lens toolkit. A three-level nested AppConfig requires updating the database host. Without lenses, this is six lines of destructuring and reconstruction. With composed lenses, it is one line: over(db_host_lens, |_| new_host, config). This demonstrates the practical value of the lens abstraction for real-world code.
🎯 Learning Outcomes
over with a composed lens updates deeply nested fields in one callCode Example
type GetFn<S, A> = Rc<dyn Fn(&S) -> A>;
type SetFn<S, A> = Rc<dyn Fn(A, &S) -> S>;
pub struct Lens<S, A> {
get: GetFn<S, A>,
set: SetFn<S, A>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
pub fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B>
where A: Clone {
let outer_get = Rc::clone(&self.get);
let outer_get2 = Rc::clone(&self.get);
let outer_set = Rc::clone(&self.set);
let inner_get = Rc::clone(&inner.get);
let inner_set = Rc::clone(&inner.set);
Lens {
get: Rc::new(move |s| inner_get(&outer_get(s))),
set: Rc::new(move |b, s| {
let a = outer_get2(s);
outer_set(inner_set(b, &a), s)
}),
}
}
}Key Differences
get/set closures; OCaml's ppx_lens or Rust's #[derive(Lens)] eliminate this.Rc-based set shares the unchanged subtree; under GC, OCaml achieves the same sharing automatically.Lens.set, Haskell's over, and Scala's Monocle all use this exact pattern for state management in large applications.OCaml Approach
OCaml's approach with ppx_lens:
(* ppx_lens auto-generates these: *)
let server_lens = AppConfig.server
let db_lens = ServerConfig.db
let host_lens = DbConfig.host
let app_db_host = server_lens |> compose db_lens |> compose host_lens
let new_config = over app_db_host (fun _ -> "new-host") config
With automatic lens generation, the entire update reduces to composition and over. OCaml's ppx_lens reduces boilerplate to near zero.
Full Source
#![allow(clippy::all)]
//! # Example 213: Practical Lens — Deeply Nested Config Update
//!
//! Lenses solve a real problem: immutably updating one field deep inside a
//! nested struct without writing boilerplate at every level.
//!
//! A `Lens<S, A>` is a pair of `(get: S → A, set: A × S → S)`.
//! Composing two lenses gives a new lens that skips the intermediate level.
//! `over(lens, f, config)` applies `f` to the focused field and rebuilds
//! every ancestor — all the clone-and-update work disappears into the lens.
use std::rc::Rc;
// ============================================================================
// Lens type — simple get/set pair
// ============================================================================
type GetFn<S, A> = Rc<dyn Fn(&S) -> A>;
type SetFn<S, A> = Rc<dyn Fn(A, &S) -> S>;
/// A lens focusing on a field of type `A` inside a structure of type `S`.
///
/// Both closures are reference-counted so a single lens can be composed into
/// multiple derived lenses without needing to copy the underlying function.
pub struct Lens<S, A> {
get: GetFn<S, A>,
set: SetFn<S, A>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
/// Build a lens from a getter and a setter.
pub fn new(get: impl Fn(&S) -> A + 'static, set: impl Fn(A, &S) -> S + 'static) -> Self {
Lens {
get: Rc::new(get),
set: Rc::new(set),
}
}
/// Read the focused value out of `s`.
pub fn view(&self, s: &S) -> A {
(self.get)(s)
}
/// Replace the focused value with `a`.
pub fn set(&self, a: A, s: &S) -> S {
(self.set)(a, s)
}
/// Apply a function to the focused value and return the updated structure.
///
/// `over(lens, f, s) = lens.set(f(lens.get(s)), s)`
///
/// OCaml: `let over l f s = l.set (f (l.get s)) s`
pub fn over(&self, f: impl FnOnce(A) -> A, s: &S) -> S {
let a = (self.get)(s);
(self.set)(f(a), s)
}
/// Compose `self` (focusing `S → A`) with `inner` (focusing `A → B`).
///
/// The resulting lens focuses `S → B`.
///
/// OCaml:
/// ```ocaml
/// let compose outer inner = {
/// get = (fun s -> inner.get (outer.get s));
/// set = (fun b s -> outer.set (inner.set b (outer.get s)) s);
/// }
/// ```
///
/// `Rc` allows `outer_get` to be shared between the `get` and `set`
/// closures of the composed lens without cloning the closure itself.
pub fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B>
where
A: Clone,
{
// Share outer_get between the two composed closures via Rc.
let outer_get = Rc::clone(&self.get);
let outer_get2 = Rc::clone(&self.get);
let outer_set = Rc::clone(&self.set);
let inner_get = Rc::clone(&inner.get);
let inner_set = Rc::clone(&inner.set);
Lens {
get: Rc::new(move |s| inner_get(&outer_get(s))),
set: Rc::new(move |b, s| {
let a = outer_get2(s);
let new_a = inner_set(b, &a);
outer_set(new_a, s)
}),
}
}
}
// ============================================================================
// Config domain — realistic 4-level nesting
// ============================================================================
#[derive(Clone, Debug, PartialEq)]
pub struct SslConfig {
pub enabled: bool,
pub cert_path: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PoolConfig {
pub min_size: u32,
pub max_size: u32,
pub timeout_ms: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DbConfig {
pub host: String,
pub port: u16,
pub pool: PoolConfig,
pub ssl: SslConfig,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CacheConfig {
pub host: String,
pub port: u16,
pub ttl_seconds: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub db: DbConfig,
pub cache: CacheConfig,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AppConfig {
pub name: String,
pub debug: bool,
pub server: ServerConfig,
}
// ============================================================================
// Atomic lenses — one per struct field
// ============================================================================
pub fn app_server() -> Lens<AppConfig, ServerConfig> {
Lens::new(
|a: &AppConfig| a.server.clone(),
|server, a| AppConfig {
server,
..a.clone()
},
)
}
pub fn app_debug() -> Lens<AppConfig, bool> {
Lens::new(
|a: &AppConfig| a.debug,
|debug, a| AppConfig { debug, ..a.clone() },
)
}
pub fn server_db() -> Lens<ServerConfig, DbConfig> {
Lens::new(
|s: &ServerConfig| s.db.clone(),
|db, s| ServerConfig { db, ..s.clone() },
)
}
pub fn server_cache() -> Lens<ServerConfig, CacheConfig> {
Lens::new(
|s: &ServerConfig| s.cache.clone(),
|cache, s| ServerConfig { cache, ..s.clone() },
)
}
pub fn db_pool() -> Lens<DbConfig, PoolConfig> {
Lens::new(
|d: &DbConfig| d.pool.clone(),
|pool, d| DbConfig { pool, ..d.clone() },
)
}
pub fn db_ssl() -> Lens<DbConfig, SslConfig> {
Lens::new(
|d: &DbConfig| d.ssl.clone(),
|ssl, d| DbConfig { ssl, ..d.clone() },
)
}
pub fn pool_max_size() -> Lens<PoolConfig, u32> {
Lens::new(
|p: &PoolConfig| p.max_size,
|max_size, p| PoolConfig {
max_size,
..p.clone()
},
)
}
pub fn pool_min_size() -> Lens<PoolConfig, u32> {
Lens::new(
|p: &PoolConfig| p.min_size,
|min_size, p| PoolConfig {
min_size,
..p.clone()
},
)
}
pub fn pool_timeout_ms() -> Lens<PoolConfig, u64> {
Lens::new(
|p: &PoolConfig| p.timeout_ms,
|timeout_ms, p| PoolConfig {
timeout_ms,
..p.clone()
},
)
}
pub fn ssl_enabled() -> Lens<SslConfig, bool> {
Lens::new(
|s: &SslConfig| s.enabled,
|enabled, s| SslConfig {
enabled,
..s.clone()
},
)
}
pub fn cache_ttl() -> Lens<CacheConfig, u64> {
Lens::new(
|c: &CacheConfig| c.ttl_seconds,
|ttl_seconds, c| CacheConfig {
ttl_seconds,
..c.clone()
},
)
}
// ============================================================================
// Composed lenses — App → deeply nested fields
// ============================================================================
/// App → u32 (pool max_size — 4 levels deep)
pub fn app_pool_max_size() -> Lens<AppConfig, u32> {
app_server()
.compose(server_db())
.compose(db_pool())
.compose(pool_max_size())
}
/// App → u32 (pool min_size)
pub fn app_pool_min_size() -> Lens<AppConfig, u32> {
app_server()
.compose(server_db())
.compose(db_pool())
.compose(pool_min_size())
}
/// App → u64 (pool timeout)
pub fn app_pool_timeout() -> Lens<AppConfig, u64> {
app_server()
.compose(server_db())
.compose(db_pool())
.compose(pool_timeout_ms())
}
/// App → bool (ssl enabled)
pub fn app_ssl_enabled() -> Lens<AppConfig, bool> {
app_server()
.compose(server_db())
.compose(db_ssl())
.compose(ssl_enabled())
}
/// App → u64 (cache ttl)
pub fn app_cache_ttl() -> Lens<AppConfig, u64> {
app_server().compose(server_cache()).compose(cache_ttl())
}
// ============================================================================
// Production configurator — the motivating use case
// ============================================================================
/// Apply all production settings in one pass.
///
/// Without lenses this would require rebuilding every ancestor struct for each
/// of the six changed fields — four levels × six changes = ~24 lines of
/// boilerplate. With lenses each change is one call; composition handles
/// the rebuilding automatically.
///
/// OCaml parallel: `let configure_for_production cfg = ...`
/// using `( %~ )` operator chaining.
pub fn configure_for_production(config: &AppConfig) -> AppConfig {
let c = app_pool_max_size().over(|n| n * 2, config);
let c = app_pool_min_size().over(|n| n * 2, &c);
let c = app_pool_timeout().set(30_000, &c);
let c = app_ssl_enabled().set(true, &c);
let c = app_cache_ttl().set(300, &c);
app_debug().set(false, &c)
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
fn dev_config() -> AppConfig {
AppConfig {
name: "myapp".into(),
debug: true,
server: ServerConfig {
host: "localhost".into(),
port: 8080,
db: DbConfig {
host: "localhost".into(),
port: 5432,
pool: PoolConfig {
min_size: 2,
max_size: 10,
timeout_ms: 5_000,
},
ssl: SslConfig {
enabled: false,
cert_path: "".into(),
},
},
cache: CacheConfig {
host: "localhost".into(),
port: 6379,
ttl_seconds: 60,
},
},
}
}
#[test]
fn test_view_top_level_field() {
assert!(app_debug().view(&dev_config()));
}
#[test]
fn test_view_4_levels_deep() {
assert_eq!(app_pool_max_size().view(&dev_config()), 10);
}
#[test]
fn test_set_top_level_does_not_disturb_siblings() {
let cfg = dev_config();
let updated = app_debug().set(false, &cfg);
assert!(!updated.debug);
assert_eq!(updated.name, "myapp");
assert_eq!(updated.server.db.pool.max_size, 10);
}
#[test]
fn test_set_deeply_nested_pool_max_size() {
let cfg = dev_config();
let updated = app_pool_max_size().set(50, &cfg);
assert_eq!(updated.server.db.pool.max_size, 50);
assert_eq!(updated.server.db.pool.min_size, 2);
assert_eq!(updated.server.db.pool.timeout_ms, 5_000);
assert_eq!(updated.server.db.host, "localhost");
assert_eq!(updated.name, "myapp");
}
#[test]
fn test_over_doubles_pool_max() {
let cfg = dev_config();
let updated = app_pool_max_size().over(|n| n * 2, &cfg);
assert_eq!(updated.server.db.pool.max_size, 20);
assert_eq!(updated.server.db.pool.min_size, 2);
}
#[test]
fn test_over_cache_ttl() {
let cfg = dev_config();
let updated = app_cache_ttl().over(|_| 300, &cfg);
assert_eq!(updated.server.cache.ttl_seconds, 300);
assert_eq!(updated.server.cache.host, "localhost");
assert_eq!(updated.server.db.pool.max_size, 10);
}
#[test]
fn test_original_not_mutated() {
let cfg = dev_config();
let _updated = app_pool_max_size().set(999, &cfg);
assert_eq!(cfg.server.db.pool.max_size, 10);
}
#[test]
fn test_configure_for_production() {
let prod = configure_for_production(&dev_config());
assert!(!prod.debug);
assert!(prod.server.db.ssl.enabled);
assert_eq!(prod.server.db.pool.max_size, 20);
assert_eq!(prod.server.db.pool.min_size, 4);
assert_eq!(prod.server.db.pool.timeout_ms, 30_000);
assert_eq!(prod.server.cache.ttl_seconds, 300);
assert_eq!(prod.name, "myapp");
assert_eq!(prod.server.db.host, "localhost");
}
#[test]
fn test_lens_law_get_after_set() {
// get(set(a, s)) = a
let cfg = dev_config();
let updated = app_pool_max_size().set(42, &cfg);
assert_eq!(app_pool_max_size().view(&updated), 42);
}
#[test]
fn test_lens_law_set_after_get() {
// set(get(s), s) = s
let cfg = dev_config();
let a = app_pool_max_size().view(&cfg);
let roundtrip = app_pool_max_size().set(a, &cfg);
assert_eq!(roundtrip, cfg);
}
#[test]
fn test_lens_law_set_set() {
// set(b, set(a, s)) = set(b, s)
let cfg = dev_config();
let after_two = app_pool_max_size().set(99, &app_pool_max_size().set(1, &cfg));
let after_one = app_pool_max_size().set(99, &cfg);
assert_eq!(after_two, after_one);
}
}#[cfg(test)]
mod tests {
use super::*;
fn dev_config() -> AppConfig {
AppConfig {
name: "myapp".into(),
debug: true,
server: ServerConfig {
host: "localhost".into(),
port: 8080,
db: DbConfig {
host: "localhost".into(),
port: 5432,
pool: PoolConfig {
min_size: 2,
max_size: 10,
timeout_ms: 5_000,
},
ssl: SslConfig {
enabled: false,
cert_path: "".into(),
},
},
cache: CacheConfig {
host: "localhost".into(),
port: 6379,
ttl_seconds: 60,
},
},
}
}
#[test]
fn test_view_top_level_field() {
assert!(app_debug().view(&dev_config()));
}
#[test]
fn test_view_4_levels_deep() {
assert_eq!(app_pool_max_size().view(&dev_config()), 10);
}
#[test]
fn test_set_top_level_does_not_disturb_siblings() {
let cfg = dev_config();
let updated = app_debug().set(false, &cfg);
assert!(!updated.debug);
assert_eq!(updated.name, "myapp");
assert_eq!(updated.server.db.pool.max_size, 10);
}
#[test]
fn test_set_deeply_nested_pool_max_size() {
let cfg = dev_config();
let updated = app_pool_max_size().set(50, &cfg);
assert_eq!(updated.server.db.pool.max_size, 50);
assert_eq!(updated.server.db.pool.min_size, 2);
assert_eq!(updated.server.db.pool.timeout_ms, 5_000);
assert_eq!(updated.server.db.host, "localhost");
assert_eq!(updated.name, "myapp");
}
#[test]
fn test_over_doubles_pool_max() {
let cfg = dev_config();
let updated = app_pool_max_size().over(|n| n * 2, &cfg);
assert_eq!(updated.server.db.pool.max_size, 20);
assert_eq!(updated.server.db.pool.min_size, 2);
}
#[test]
fn test_over_cache_ttl() {
let cfg = dev_config();
let updated = app_cache_ttl().over(|_| 300, &cfg);
assert_eq!(updated.server.cache.ttl_seconds, 300);
assert_eq!(updated.server.cache.host, "localhost");
assert_eq!(updated.server.db.pool.max_size, 10);
}
#[test]
fn test_original_not_mutated() {
let cfg = dev_config();
let _updated = app_pool_max_size().set(999, &cfg);
assert_eq!(cfg.server.db.pool.max_size, 10);
}
#[test]
fn test_configure_for_production() {
let prod = configure_for_production(&dev_config());
assert!(!prod.debug);
assert!(prod.server.db.ssl.enabled);
assert_eq!(prod.server.db.pool.max_size, 20);
assert_eq!(prod.server.db.pool.min_size, 4);
assert_eq!(prod.server.db.pool.timeout_ms, 30_000);
assert_eq!(prod.server.cache.ttl_seconds, 300);
assert_eq!(prod.name, "myapp");
assert_eq!(prod.server.db.host, "localhost");
}
#[test]
fn test_lens_law_get_after_set() {
// get(set(a, s)) = a
let cfg = dev_config();
let updated = app_pool_max_size().set(42, &cfg);
assert_eq!(app_pool_max_size().view(&updated), 42);
}
#[test]
fn test_lens_law_set_after_get() {
// set(get(s), s) = s
let cfg = dev_config();
let a = app_pool_max_size().view(&cfg);
let roundtrip = app_pool_max_size().set(a, &cfg);
assert_eq!(roundtrip, cfg);
}
#[test]
fn test_lens_law_set_set() {
// set(b, set(a, s)) = set(b, s)
let cfg = dev_config();
let after_two = app_pool_max_size().set(99, &app_pool_max_size().set(1, &cfg));
let after_one = app_pool_max_size().set(99, &cfg);
assert_eq!(after_two, after_one);
}
}
Deep Comparison
OCaml vs Rust: Practical Lens — Deeply Nested Config Update
The Problem
Real configs are deeply nested. Without lenses, updating App → Server → DB → Pool → max_size
requires manually rebuilding every ancestor: clone the pool with the new field, clone the db
with the new pool, clone the server with the new db, clone the app with the new server.
Four levels of boilerplate for one field change. Lenses eliminate this.
Side-by-Side Code
OCaml — lens type and composition
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 b s -> outer.set (inner.set b (outer.get s)) s);
}
let over l f s = l.set (f (l.get s)) s
Rust (idiomatic) — lens struct with Rc-shared closures
type GetFn<S, A> = Rc<dyn Fn(&S) -> A>;
type SetFn<S, A> = Rc<dyn Fn(A, &S) -> S>;
pub struct Lens<S, A> {
get: GetFn<S, A>,
set: SetFn<S, A>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
pub fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B>
where A: Clone {
let outer_get = Rc::clone(&self.get);
let outer_get2 = Rc::clone(&self.get);
let outer_set = Rc::clone(&self.set);
let inner_get = Rc::clone(&inner.get);
let inner_set = Rc::clone(&inner.set);
Lens {
get: Rc::new(move |s| inner_get(&outer_get(s))),
set: Rc::new(move |b, s| {
let a = outer_get2(s);
outer_set(inner_set(b, &a), s)
}),
}
}
}
Rust (functional) — atomic lenses composed to deep focus
// Four atomic lenses composed into one App → u32 lens (pool max_size, 4 levels deep)
pub fn app_pool_max_size() -> Lens<AppConfig, u32> {
app_server()
.compose(server_db())
.compose(db_pool())
.compose(pool_max_size())
}
// Using it: no manual clone chain
pub fn configure_for_production(config: &AppConfig) -> AppConfig {
let c = app_pool_max_size().over(|n| n * 2, config);
let c = app_ssl_enabled().set(true, &c);
app_debug().set(false, &c)
}
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Lens type | ('s, 'a) lens = { get: 's -> 'a; set: 'a -> 's -> 's } | struct Lens<S, A> { get: Rc<dyn Fn(&S)->A>, set: Rc<dyn Fn(A,&S)->S> } |
| Composition | compose : ('s,'a) lens -> ('a,'b) lens -> ('s,'b) lens | fn compose<B>(self, inner: Lens<A,B>) -> Lens<S,B> |
| Over | over : ('s,'a) lens -> ('a->'a) -> 's -> 's | fn over(&self, f: impl FnOnce(A)->A, s: &S) -> S |
| Immutability | structural, algebraic types | owned values + ..struct.clone() spread |
Key Insights
{ s with field = v } is mirrored by Rust's Struct { field: v, ..s.clone() }. Both express "copy everything except this field", making lens setters concise at every level.compose function can freely capture outer.get in both get and set. Rust requires Rc::clone to give each composed closure its own reference-counted handle to the same underlying function — no actual data is copied.let ( |>> ) = compose so lenses chain with app_server |>> server_db |>> db_pool |>> pool_max_size. Rust uses method-chaining .compose() — syntactically different, semantically identical.set or over call regardless of nesting depth.get(set(a,s))=a, set(get(s),s)=s, set(b,set(a,s))=set(b,s) — because the setter rebuilds the full ancestor chain using the actual getter value, not a stale copy.When to Use Each Style
Use atomic lenses when a field is updated in multiple places — define it once, reuse everywhere.
Use composed lenses when transformations target fields at different nesting depths and you want the call site to read as a single focused operation.
**Use over instead of set** when the new value depends on the old one (e.g., doubling pool size), keeping the transformation co-located with the lens rather than split across a view + set.
Exercises
AppConfig.server.db.primary.host and compose a four-lens chain.update_port(config, new_port) using composed lenses without touching the database host.(lens, new_value) and applies all of them to the config.