Trust is earned, not given

A different perspective

2025-01-21 · Projects

Rust, part 12: serde and the derive ecosystem

Part 12from the Rust series · 15 parts in all

The first non-negotiable dependency in most Rust projects is serde, and it is worth understanding rather than copying from a template — because its attributes are also its API design, and getting them wrong produces data formats that are subtly incompatible.

A struct is a schema

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Order {
    pub id: u64,

    /// Defaults when the field is absent, so adding it is not a breaking change.
    #[serde(default)]
    pub note: String,

    /// Renamed at the boundary without renaming it in Rust.
    #[serde(rename = "total", alias = "totalCents")]
    pub total_cents: u32,

    /// Never written, still readable - for a deprecated input field.
    #[serde(skip_serializing)]
    pub legacy_code: Option<String>,
}

deny_unknown_fields is the one to think hardest about. Without it, a producer can add a field and your service will silently ignore it — convenient, and how you find out six months later that a price was attached to an order you did not read. With it, the same change is a loud 400. Pick per boundary: strict for internal contracts, lenient for a third-party API you do not control.

The shape of the data format is a decision

// Internally tagged: {"type": "card", ...fields}
#[serde(tag = "type", rename_all = "lowercase")]
enum Payment { Card { last4: String }, Bank { iban: String } }

// Externally tagged (the default): {"Card": {...}} - the safe default,
// and the only shape that works for every variant.
#[derive(Serialize, Deserialize)]
enum Status { Open, Closed { at: String } }

Serde supports four enum representations and each has cases it cannot handle: internally tagged cannot carry a newtype variant holding a non-map, adjacently tagged needs a free field name, and untagged loses the error message. Changing the representation after you ship is a breaking change to a wire format, so choose deliberately the first time.

What the derive is doing, and what to reach for instead

#[derive(Serialize)] expands to a hand-written-looking impl that calls serializer.serialize_struct(..) field by field. That matters for two reasons: compile time grows with the number of derived types, and when you need something the derive cannot express you write the impl by hand or reach for #[serde(with = "module")], which is how custom date formats, decimal types and newtype wrappers are usually handled.

Two adjacent crates are worth knowing on the same day: serde_json is the format crate — one per format, and they are not part of serde itself — and serde_with collects the "with module" helpers you would otherwise write four times. Next: the release that made the language's biggest syntactic addition in years.