ExamplesBy LevelBy TopicLearning Paths
201 Advanced

Introduction to Lenses — The Nested Update Problem

Functional Programming

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

  • • Understand why deeply nested immutable updates are problematic without lenses
  • • Learn what a Lens is: a composable pair of get (read) and set (write) functions
  • • See how lens composition allows updating deeply nested fields with one call
  • • Appreciate the real-world motivation: configuration management, state management, ORMs
  • Code 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

  • Derive macros: Rust crates like lens-rs provide #[derive(Lens)] to auto-generate lenses; OCaml's ppx_lens does the same — both reduce boilerplate.
  • Record syntax: OCaml's { r with field = v } updates one level in-place syntactically; Rust's StructName { field: v, ..r } is similar but slightly more verbose.
  • Composability: Lens composition (|>>) in OCaml and .compose() in Rust are equivalent; both produce lenses that traverse multiple levels in one shot.
  • Practical use: Facebook's 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);
        }
    }
    ✓ Tests Rust test suite
    #[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

  • Write a over function that applies f to the field focused by a lens and returns the updated structure.
  • Compose three lenses to focus on config.server.db.port and increment it.
  • Implement modify_all(lens, f, configs: Vec<Config>) -> Vec<Config> using map + over.
  • Open Source Repos