ExamplesBy LevelBy TopicLearning Paths
773 Fundamental

773-serde-attributes-concept — Serde Attributes Concept

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "773-serde-attributes-concept — Serde Attributes Concept" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. `serde`'s attribute system — `#[serde(rename = "...")]`, `#[serde(skip)]`, `#[serde(default)]`, `#[serde(flatten)]` — transforms how types map to their serialized representation. Key difference from OCaml: 1. **Unified vs per

Tutorial

The Problem

serde's attribute system — #[serde(rename = "...")], #[serde(skip)], #[serde(default)], #[serde(flatten)] — transforms how types map to their serialized representation. Understanding these attributes is essential for working with external JSON APIs, legacy formats, and versioned protocols. This example demystifies serde attributes by showing what behavior they encode without the actual serde crate, making the mental model clear.

🎯 Learning Outcomes

  • • Understand #[serde(rename = "user_name")] — changes the JSON key without changing the Rust field name
  • • Understand #[serde(skip)] — omits a field from serialization (useful for derived/sensitive data)
  • • Understand #[serde(default)] — uses Default::default() when the field is absent during deserialization
  • • Understand #[serde(flatten)] — inlines a nested struct's fields into the parent object
  • • Map each attribute to the code it conceptually generates
  • Code Example

    #[derive(Serialize)]
    struct User {
        id: u64,
        #[serde(rename = "user_name")]
        username: String,
        #[serde(skip)]
        password_hash: String,
        #[serde(default)]
        display_name: Option<String>,
    }

    Key Differences

  • Unified vs per-format: Serde's attributes work across all formats (JSON, TOML, YAML, MessagePack); OCaml requires separate attribute sets per ppx.
  • Runtime simulation: This example simulates attribute effects at runtime via FieldConfig; real serde processes attributes at compile time via proc macros.
  • flatten: Serde's #[serde(flatten)] is notoriously complex to implement; OCaml's ppx_yojson_conv doesn't support it natively.
  • Default values: Serde's #[serde(default = "fn_name")] calls a custom function; OCaml's [@default expr] evaluates an expression at type definition time.
  • OCaml Approach

    OCaml's ppx_sexp_conv attributes: [@sexp.opaque] hides a field, [@sexp_list] treats a field as a list, [@default 0] provides a default. ppx_yojson_conv uses [@yojson.option], [@key "name"], and [@yojson.drop_default]. Unlike serde's unified attribute system, OCaml ppx attributes are ppx-specific and must be duplicated per serialization format.

    Full Source

    #![allow(clippy::all)]
    //! # Serde Attributes Concept
    //!
    //! Understanding serde's attribute system without the actual serde crate.
    
    use std::collections::HashMap;
    
    /// Simulated field attributes
    #[derive(Debug, Clone)]
    pub struct FieldConfig {
        pub rename: Option<String>,
        pub skip: bool,
        pub default: bool,
        pub flatten: bool,
    }
    
    impl Default for FieldConfig {
        fn default() -> Self {
            FieldConfig {
                rename: None,
                skip: false,
                default: false,
                flatten: false,
            }
        }
    }
    
    /// A struct with "serde-like" configuration
    #[derive(Debug)]
    pub struct User {
        pub id: u64,
        pub username: String,
        pub email: String,
        pub password_hash: String,        // Would have #[serde(skip)]
        pub display_name: Option<String>, // Would have #[serde(default)]
    }
    
    impl User {
        pub fn field_configs() -> HashMap<&'static str, FieldConfig> {
            let mut configs = HashMap::new();
    
            configs.insert("id", FieldConfig::default());
            configs.insert(
                "username",
                FieldConfig {
                    rename: Some("user_name".to_string()),
                    ..Default::default()
                },
            );
            configs.insert("email", FieldConfig::default());
            configs.insert(
                "password_hash",
                FieldConfig {
                    skip: true,
                    ..Default::default()
                },
            );
            configs.insert(
                "display_name",
                FieldConfig {
                    default: true,
                    ..Default::default()
                },
            );
    
            configs
        }
    
        /// Serialize to JSON-like string
        pub fn to_json(&self) -> String {
            let configs = Self::field_configs();
            let mut pairs = Vec::new();
    
            // id
            pairs.push(r#""id": "#.to_owned() + &self.id.to_string());
    
            // username (renamed to user_name)
            if let Some(cfg) = configs.get("username") {
                let key = cfg.rename.as_deref().unwrap_or("username");
                pairs.push(format!(r#""{}": "{}""#, key, self.username));
            }
    
            // email
            pairs.push(format!(r#""email": "{}""#, self.email));
    
            // password_hash - skipped!
            // (not included)
    
            // display_name (optional with default)
            if let Some(name) = &self.display_name {
                pairs.push(format!(r#""display_name": "{}""#, name));
            }
    
            format!("{{{}}}", pairs.join(", "))
        }
    }
    
    /// Demonstrate rename_all
    #[derive(Debug)]
    pub struct Config {
        pub database_host: String,
        pub database_port: u16,
        pub max_connections: u32,
    }
    
    impl Config {
        /// Serialize with snake_case to camelCase conversion
        pub fn to_camel_case_json(&self) -> String {
            let pairs = vec![
                format!(r#""databaseHost": "{}""#, self.database_host),
                format!(r#""databasePort": {}"#, self.database_port),
                format!(r#""maxConnections": {}"#, self.max_connections),
            ];
            format!("{{{}}}", pairs.join(", "))
        }
    
        /// Serialize with snake_case to kebab-case
        pub fn to_kebab_case_json(&self) -> String {
            let pairs = vec![
                format!(r#""database-host": "{}""#, self.database_host),
                format!(r#""database-port": {}"#, self.database_port),
                format!(r#""max-connections": {}"#, self.max_connections),
            ];
            format!("{{{}}}", pairs.join(", "))
        }
    }
    
    /// Convert snake_case to camelCase
    pub fn snake_to_camel(s: &str) -> String {
        let mut result = String::new();
        let mut capitalize_next = false;
    
        for c in s.chars() {
            if c == '_' {
                capitalize_next = true;
            } else if capitalize_next {
                result.push(c.to_ascii_uppercase());
                capitalize_next = false;
            } else {
                result.push(c);
            }
        }
    
        result
    }
    
    /// Convert snake_case to kebab-case
    pub fn snake_to_kebab(s: &str) -> String {
        s.replace('_', "-")
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_user_to_json() {
            let user = User {
                id: 1,
                username: "alice".to_string(),
                email: "alice@example.com".to_string(),
                password_hash: "secret123".to_string(),
                display_name: Some("Alice".to_string()),
            };
            let json = user.to_json();
    
            assert!(json.contains(r#""id": 1"#));
            assert!(json.contains(r#""user_name": "alice""#)); // renamed
            assert!(!json.contains("password")); // skipped
            assert!(json.contains(r#""display_name": "Alice""#));
        }
    
        #[test]
        fn test_config_camel_case() {
            let config = Config {
                database_host: "localhost".to_string(),
                database_port: 5432,
                max_connections: 100,
            };
            let json = config.to_camel_case_json();
    
            assert!(json.contains("databaseHost"));
            assert!(json.contains("databasePort"));
            assert!(json.contains("maxConnections"));
        }
    
        #[test]
        fn test_snake_to_camel() {
            assert_eq!(snake_to_camel("hello_world"), "helloWorld");
            assert_eq!(snake_to_camel("database_host"), "databaseHost");
        }
    
        #[test]
        fn test_snake_to_kebab() {
            assert_eq!(snake_to_kebab("hello_world"), "hello-world");
            assert_eq!(snake_to_kebab("database_host"), "database-host");
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_user_to_json() {
            let user = User {
                id: 1,
                username: "alice".to_string(),
                email: "alice@example.com".to_string(),
                password_hash: "secret123".to_string(),
                display_name: Some("Alice".to_string()),
            };
            let json = user.to_json();
    
            assert!(json.contains(r#""id": 1"#));
            assert!(json.contains(r#""user_name": "alice""#)); // renamed
            assert!(!json.contains("password")); // skipped
            assert!(json.contains(r#""display_name": "Alice""#));
        }
    
        #[test]
        fn test_config_camel_case() {
            let config = Config {
                database_host: "localhost".to_string(),
                database_port: 5432,
                max_connections: 100,
            };
            let json = config.to_camel_case_json();
    
            assert!(json.contains("databaseHost"));
            assert!(json.contains("databasePort"));
            assert!(json.contains("maxConnections"));
        }
    
        #[test]
        fn test_snake_to_camel() {
            assert_eq!(snake_to_camel("hello_world"), "helloWorld");
            assert_eq!(snake_to_camel("database_host"), "databaseHost");
        }
    
        #[test]
        fn test_snake_to_kebab() {
            assert_eq!(snake_to_kebab("hello_world"), "hello-world");
            assert_eq!(snake_to_kebab("database_host"), "database-host");
        }
    }

    Deep Comparison

    OCaml vs Rust: Serde Attributes Concept

    Field Attributes

    Rust (with serde)

    #[derive(Serialize)]
    struct User {
        id: u64,
        #[serde(rename = "user_name")]
        username: String,
        #[serde(skip)]
        password_hash: String,
        #[serde(default)]
        display_name: Option<String>,
    }
    

    OCaml (ppx_deriving_yojson)

    type user = {
      id: int;
      username: string [@key "user_name"];
      (* No direct skip - use wrapper type *)
      display_name: string option [@default None];
    } [@@deriving yojson]
    

    Common Serde Attributes

    AttributePurpose
    renameDifferent JSON key name
    skipDon't serialize this field
    defaultUse Default::default() if missing
    flattenInline nested struct
    rename_allApply case conversion to all fields

    Case Conversion

    Rust

    #[derive(Serialize)]
    #[serde(rename_all = "camelCase")]
    struct Config {
        database_host: String,  // -> "databaseHost"
    }
    

    Manual Implementation

    pub fn snake_to_camel(s: &str) -> String {
        let mut result = String::new();
        let mut cap_next = false;
        for c in s.chars() {
            if c == '_' { cap_next = true; }
            else if cap_next { result.push(c.to_ascii_uppercase()); cap_next = false; }
            else { result.push(c); }
        }
        result
    }
    

    Key Differences

    AspectOCamlRust
    Attribute syntax[@key ...]#[serde(...)]
    Skip fieldUse wrapper type#[serde(skip)]
    Derive[@@deriving yojson]#[derive(Serialize)]

    Exercises

  • Implement a serialize_with_rename(config: &FieldConfig, field_name: &str) -> &str helper that returns the wire name — using config.rename if set, otherwise field_name.
  • Write a deserialize_user(json: &HashMap<String, String>) -> User that applies default and rename configs during deserialization.
  • Simulate #[serde(skip_serializing_if = "Option::is_none")] by adding a skip_if_none: bool field to FieldConfig and implementing the conditional skip logic.
  • Open Source Repos