rust 84 lines · 2 tabs

Deserializing Optional and Renamed JSON API Fields with Serde in Rust

Shared by codesnips Jul 2026
2 tabs
use serde::{Deserialize, Deserializer};
use time::OffsetDateTime;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserProfile {
    #[serde(rename = "userId")]
    pub id: u64,

    pub display_name: String,

    #[serde(default)]
    pub bio: Option<String>,

    #[serde(default)]
    pub roles: Vec<String>,

    pub address: Option<Address>,

    #[serde(deserialize_with = "parse_timestamp")]
    pub joined_at: OffsetDateTime,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Address {
    pub line1: String,
    #[serde(default)]
    pub line2: Option<String>,
    pub postal_code: String,
    pub country: String,
}

fn parse_timestamp<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = String::deserialize(deserializer)?;
    OffsetDateTime::parse(&raw, &time::format_description::well_known::Rfc3339)
        .map_err(serde::de::Error::custom)
}
2 files · rust Explain with highlit

Real-world JSON APIs rarely match Rust's naming conventions or guarantee every field is present, so a client needs a data layer that maps external camelCase keys onto idiomatic snake_case structs and tolerates missing or null values without panicking. The models.rs tab shows how serde handles this declaratively through attributes rather than hand-written parsing.

At the container level, #[serde(rename_all = "camelCase")] on UserProfile rewrites every field name during deserialization, so display_name binds to displayName on the wire without per-field annotations. Individual overrides use #[serde(rename = "...")], as with id mapping to the provider's userId. Truly optional data is modeled as Option<T>, and #[serde(default)] on bio means a completely absent key deserializes to None instead of raising an error — the distinction being that Option alone still requires the key to appear (even as JSON null), while default also covers omission.

The #[serde(default)] on the roles vector demonstrates a common pattern: a missing array becomes an empty Vec rather than forcing callers to unwrap. The nested Address struct shows that these attributes compose recursively, and deserialize_with on joined_at delegates to a custom parse_timestamp function for a format serde can't infer.

The client.rs tab wires this into an HTTP call. fetch_profile uses reqwest to request the resource, calls error_for_status to convert non-2xx responses into errors early, and then .json::<UserProfile>() drives the whole deserialization graph. Because the fallible steps all return compatible error types, the function threads them together with ? and a thiserror-style ApiError enum, keeping transport failures distinct from decoding failures.

The trade-off of this approach is that malformed-but-plausible payloads fail loudly at the boundary, which is usually desirable: bugs surface as typed ApiError::Decode values instead of silent Nones deep in business logic. When an API is volatile, developers lean harder on default and Option to stay forward-compatible; when it is strict, they omit them so missing fields become hard errors. This attribute-driven style keeps the parsing intent visible next to the data model.


Related snips

Share this code

Here's the card — post it anywhere.

Deserializing Optional and Renamed JSON API Fields with Serde in Rust — share card
Link copied