ExamplesBy LevelBy TopicLearning Paths
062 Intermediate

062 — Records (Structs)

Functional Programming

Tutorial

The Problem

Records (called structs in Rust) are the product type of a type system — a value that bundles multiple named fields. OCaml's type point = { x: float; y: float } and Rust's struct Point { x: f64, y: f64 } are direct equivalents. Records are the foundation for representing real-world entities: users, configurations, geometric shapes, network requests.

The record update syntax — creating a new record with most fields from an existing one, changing only a few — is a functional programming staple. It appears in immutable state management (Redux reducers, Elm architecture), configuration management, and "builder" patterns.

🎯 Learning Outcomes

  • • Define structs with named fields and implement methods via impl
  • • Use struct update syntax Config { debug: true, ..Config::default_config() } for partial updates
  • • Derive Debug, Clone, Copy for common struct utilities
  • • Understand when to use Copy (small, stack-allocated values) vs Clone (heap-allocated)
  • • Pattern-match on struct fields using destructuring
  • • Define Rust struct as the equivalent of OCaml records with named fields and implement methods in an impl block
  • • Use #[derive(Debug, Clone, PartialEq)] to auto-generate common trait implementations without boilerplate
  • Code Example

    #![allow(clippy::all)]
    // 062: Records (Structs)
    // Named fields, creation, update syntax, pattern matching
    
    // Approach 1: Basic struct
    #[derive(Debug, Clone, Copy)]
    struct Point {
        x: f64,
        y: f64,
    }
    
    impl Point {
        fn origin() -> Self {
            Point { x: 0.0, y: 0.0 }
        }
    
        fn distance(&self, other: &Point) -> f64 {
            let dx = self.x - other.x;
            let dy = self.y - other.y;
            (dx * dx + dy * dy).sqrt()
        }
    }
    
    // Approach 2: Struct update syntax
    #[derive(Debug, Clone)]
    struct Config {
        host: String,
        port: u16,
        debug: bool,
        timeout: u32,
    }
    
    impl Config {
        fn default_config() -> Self {
            Config {
                host: "localhost".to_string(),
                port: 8080,
                debug: false,
                timeout: 30,
            }
        }
    }
    
    fn dev_config() -> Config {
        Config {
            debug: true,
            port: 3000,
            ..Config::default_config()
        }
    }
    
    fn prod_config() -> Config {
        Config {
            host: "prod.example.com".to_string(),
            timeout: 60,
            ..Config::default_config()
        }
    }
    
    // Approach 3: Destructuring
    fn describe_config(config: &Config) -> String {
        let Config {
            host, port, debug, ..
        } = config;
        format!("{}:{}{}", host, port, if *debug { " [DEBUG]" } else { "" })
    }
    
    fn is_local(config: &Config) -> bool {
        config.host == "localhost" || config.host == "127.0.0.1"
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_point() {
            let o = Point::origin();
            let p = Point { x: 3.0, y: 4.0 };
            assert!((o.distance(&p) - 5.0).abs() < 0.001);
        }
    
        #[test]
        fn test_struct_update() {
            let dev = dev_config();
            assert!(dev.debug);
            assert_eq!(dev.port, 3000);
            assert_eq!(dev.host, "localhost");
        }
    
        #[test]
        fn test_prod_config() {
            let prod = prod_config();
            assert_eq!(prod.timeout, 60);
            assert_eq!(prod.host, "prod.example.com");
        }
    
        #[test]
        fn test_describe() {
            assert_eq!(describe_config(&dev_config()), "localhost:3000 [DEBUG]");
        }
    
        #[test]
        fn test_is_local() {
            assert!(is_local(&Config::default_config()));
            assert!(!is_local(&prod_config()));
        }
    }

    Key Differences

  • Update syntax: OCaml: { record with field = value }. Rust: StructName { field: value, ..record }. Both create a new record with specified fields overridden.
  • Mutability: OCaml records are immutable by default; add mutable per field. Rust structs are immutable by default; let mut s = Struct {...} makes the entire binding mutable.
  • Methods: Rust methods are defined in impl blocks separate from the struct. OCaml has module-level functions; methods are a convention, not a language feature.
  • **Copy trait**: Rust's Copy trait marks types that can be copied by value on assignment (stack-only types). OCaml's uniform representation means all values are either boxed (heap) or unboxed (stack) based on size, without explicit marking.
  • Struct vs record: OCaml's type point = { x: float; y: float } and Rust's struct Point { x: f64, y: f64 } are isomorphic. Both are product types with named fields.
  • Functional update: OCaml's record update { p with x = 1.0 } creates a new record with one field changed. Rust has no built-in equivalent — use Point { x: 1.0, ..p } (struct update syntax).
  • Deriving traits: #[derive(Debug, Clone, PartialEq)] in Rust auto-generates common impls. OCaml uses [@@deriving show, eq] (ppx_deriving) for the same effect.
  • Destructuring: let Point { x, y } = point in Rust extracts fields by name. OCaml: let { x; y } = point. Both support nested destructuring in patterns.
  • OCaml Approach

    OCaml record: type point = { x: float; y: float }. Record creation: { x = 1.0; y = 2.0 }. Update syntax: { config with debug = true; port = 3000 } — directly parallel to Rust's ..config. Pattern matching: let { x; y } = point in .... OCaml records are immutable by default; mutable fields use mutable x: float.

    Full Source

    #![allow(clippy::all)]
    // 062: Records (Structs)
    // Named fields, creation, update syntax, pattern matching
    
    // Approach 1: Basic struct
    #[derive(Debug, Clone, Copy)]
    struct Point {
        x: f64,
        y: f64,
    }
    
    impl Point {
        fn origin() -> Self {
            Point { x: 0.0, y: 0.0 }
        }
    
        fn distance(&self, other: &Point) -> f64 {
            let dx = self.x - other.x;
            let dy = self.y - other.y;
            (dx * dx + dy * dy).sqrt()
        }
    }
    
    // Approach 2: Struct update syntax
    #[derive(Debug, Clone)]
    struct Config {
        host: String,
        port: u16,
        debug: bool,
        timeout: u32,
    }
    
    impl Config {
        fn default_config() -> Self {
            Config {
                host: "localhost".to_string(),
                port: 8080,
                debug: false,
                timeout: 30,
            }
        }
    }
    
    fn dev_config() -> Config {
        Config {
            debug: true,
            port: 3000,
            ..Config::default_config()
        }
    }
    
    fn prod_config() -> Config {
        Config {
            host: "prod.example.com".to_string(),
            timeout: 60,
            ..Config::default_config()
        }
    }
    
    // Approach 3: Destructuring
    fn describe_config(config: &Config) -> String {
        let Config {
            host, port, debug, ..
        } = config;
        format!("{}:{}{}", host, port, if *debug { " [DEBUG]" } else { "" })
    }
    
    fn is_local(config: &Config) -> bool {
        config.host == "localhost" || config.host == "127.0.0.1"
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_point() {
            let o = Point::origin();
            let p = Point { x: 3.0, y: 4.0 };
            assert!((o.distance(&p) - 5.0).abs() < 0.001);
        }
    
        #[test]
        fn test_struct_update() {
            let dev = dev_config();
            assert!(dev.debug);
            assert_eq!(dev.port, 3000);
            assert_eq!(dev.host, "localhost");
        }
    
        #[test]
        fn test_prod_config() {
            let prod = prod_config();
            assert_eq!(prod.timeout, 60);
            assert_eq!(prod.host, "prod.example.com");
        }
    
        #[test]
        fn test_describe() {
            assert_eq!(describe_config(&dev_config()), "localhost:3000 [DEBUG]");
        }
    
        #[test]
        fn test_is_local() {
            assert!(is_local(&Config::default_config()));
            assert!(!is_local(&prod_config()));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_point() {
            let o = Point::origin();
            let p = Point { x: 3.0, y: 4.0 };
            assert!((o.distance(&p) - 5.0).abs() < 0.001);
        }
    
        #[test]
        fn test_struct_update() {
            let dev = dev_config();
            assert!(dev.debug);
            assert_eq!(dev.port, 3000);
            assert_eq!(dev.host, "localhost");
        }
    
        #[test]
        fn test_prod_config() {
            let prod = prod_config();
            assert_eq!(prod.timeout, 60);
            assert_eq!(prod.host, "prod.example.com");
        }
    
        #[test]
        fn test_describe() {
            assert_eq!(describe_config(&dev_config()), "localhost:3000 [DEBUG]");
        }
    
        #[test]
        fn test_is_local() {
            assert!(is_local(&Config::default_config()));
            assert!(!is_local(&prod_config()));
        }
    }

    Deep Comparison

    Core Insight

    Records/structs group named fields. Both languages support pattern matching on fields and functional update syntax (creating a new value with some fields changed).

    OCaml Approach

  • type t = { field1: type1; field2: type2 } — record definition
  • { r with field = new_value } — functional update
  • • Mutable fields with mutable keyword (rare)
  • • Pattern match: let { field1; field2 } = r
  • Rust Approach

  • struct T { field1: Type1, field2: Type2 } — struct definition
  • T { field: new_val, ..old } — struct update syntax (moves non-Copy fields!)
  • • All fields private by default; pub for visibility
  • • Destructuring: let T { field1, field2 } = s;
  • Comparison Table

    FeatureOCamlRust
    Definetype t = { x: int }struct T { x: i32 }
    Create{ x = 5 }T { x: 5 }
    Accessr.xs.x
    Update{ r with x = 10 }T { x: 10, ..s }
    Destructurelet { x; y } = rlet T { x, y } = s
    Mutabilitymutable per fieldlet mut s (all or nothing)

    Exercises

  • Builder pattern: Write a ConfigBuilder struct with setter methods that each return Self (for chaining) and a build() -> Config method. This is idiomatic Rust for structs with many optional fields.
  • Serde serialization: Add #[derive(serde::Serialize, serde::Deserialize)] to Config and serialize/deserialize to/from JSON using serde_json.
  • Default trait: Implement Default for Config using #[derive(Default)] (set all fields to their defaults) or a manual impl Default. Compare with the manual default_config() function.
  • Builder pattern: Implement a builder for a Config struct with many optional fields, using the builder pattern (a struct ConfigBuilder with a chain of setters and a final .build() -> Result<Config, String>).
  • Record lenses: Implement getter and setter functions for each field of a Point struct, then compose them to update nested fields — introducing the concept of lenses without a dedicated library.
  • Open Source Repos