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.