Skip to Content
Course content

165: Serde Attributes: rename, skip, default

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've noticed a recurring pattern when developers first start working with Serde: they treat their Rust structs as direct mirrors of the JSON they're consuming. They'll look at a JSON response from a third-party API, see a field called userId, and immediately name their Rust field userId. Then, they're hit with a barrage of compiler warnings telling them that userId should be user_id to follow Rust's naming conventions. To silence the warnings, they use #[allow(non_snake_case)].

This is a mistake. You should never compromise your Rust code's idiom just to satisfy a poorly named external API. The data format is a contract, but your internal representation is your domain. You can—and should—keep them separate.

Stop naming Rust fields to match JSON keys

Instead of fighting the compiler, we use #[serde(rename = "external_name")]. This tells Serde: "In the Rust world, this is user_id, but when you're talking to the wire, call it userId."

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct UserProfile {
    // We want snake_case in Rust, but the API sends camelCase
    #[serde(rename = "userId")]
    user_id: u32,
    
    #[serde(rename = "fullName")]
    full_name: String,
}

fn main() {
    let json = r#"{"userId": 101, "fullName": "Alice Smith"}"#;
    let user: UserProfile = serde_json::from_str(json).unwrap();
    println!("{:?}", user); // UserProfile { user_id: 101, full_name: "Alice Smith" }
}

If every single field in your struct needs to be converted from snake_case to camelCase, don't rename them one by one. You can put #[serde(rename_all = "camelCase")] at the top of the struct, and Serde will handle the translation for every field automatically. It's a massive time-saver.

Stop serializing internal state to the wire

Sometimes your struct needs fields that are essential for your program's logic but would be nonsensical (or a security risk) to send back to a client or save to a file. A common example is a cached calculation or a local session token.

If you leave these in, Serde will try to serialize them. Use #[serde(skip)] to tell Serde to completely ignore that field during both serialization and deserialization.

#[derive(Serialize, Deserialize, Debug)]
struct GameSession {
    player_id: String,
    score: u32,
    
    // We need this for local logic, but we don't want to send it 
    // back to the server or save it in the save-game JSON.
    #[serde(skip)]
    local_cache_timestamp: u64,
}

One detail to watch out for: if you skip a field, Serde still needs to know what to put there when it deserializes JSON into that struct. It will use the type's Default implementation. If your type doesn't implement Default, you'll get a compile error. This leads us right into the next tool.

Stop relying solely on Option for missing keys

The instinct for many is to make every optional JSON field an Option<T>. While that's often correct, it forces you to unwrap or match on that option every single time you use the value in your code, even if there's a perfectly sensible default value.

#[serde(default)] allows you to say: "If this key is missing from the JSON, just use the default value for this type."

#[derive(Serialize, Deserialize, Debug)]
struct AppConfig {
    host: String,
    
    // If "port" is missing in the JSON, it defaults to 0 (u16's default)
    #[serde(default)]
    port: u16,
    
    // You can also point to a custom function for a specific default
    #[serde(default = "default_timeout")]
    timeout_seconds: u32,
}

fn default_timeout() -> u32 { 30 }

fn main() {
    let json = r#"{"host": "localhost"}"#;
    let config: AppConfig = serde_json::from_str(json).unwrap();
    
    println!("Port: {}, Timeout: {}", config.port, config.timeout_seconds);
    // Port: 0, Timeout: 30
}

I personally prefer #[serde(default = "path")] over Option whenever a "sane default" exists. It cleans up the call sites of your functions because you're dealing with concrete values instead of wrapping everything in if let Some(...).




📋 Practical Task

Refactoring the Legacy Weather API Client

You are integrating a legacy Weather API. The API is inconsistent: it uses PascalCase for some fields, sends an internal debug_id that you must not send back to the server, and sometimes omits the units field (which should default to "metric").

Fix the WeatherResponse struct below. You must:

  1. Rename Temperature and Humidity to follow Rust's snake_case convention without triggering compiler warnings.
  2. Ensure the internal_process_id field is completely ignored by Serde.
  3. Ensure that if units is missing from the JSON, it defaults to "metric" using a custom function.
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct WeatherResponse {
    // TODO: Rename this to 'temperature'
    Temperature: f64,
    
    // TODO: Rename this to 'humidity'
    Humidity: f64,
    
    // TODO: Ensure this is skipped during serialization/deserialization
    internal_process_id: u64,
    
    // TODO: Default this to "metric" if missing
    units: String,
}

// TODO: Create the default function for units here

fn main() {
    let json = r#"{"Temperature": 22.5, "Humidity": 60, "internal_process_id": 999}"#;
    let weather: WeatherResponse = serde_json::from_str(json).expect("Failed to parse");
    
    println!("{:?}", weather);
    // Expected output: WeatherResponse { temperature: 22.5, humidity: 60.0, internal_process_id: 0, units: "metric" }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.