Understanding JSON → Rust
serde does most of the work.
Why every Rust JSON codegen tool emits the same skeleton, and the handful of attribute choices that matter for it to be useful.
The serde skeleton.
Every Rust JSON struct starts with #[derive(Serialize, Deserialize, Debug)] and a body that names each field with its inferred type. The serde derive macros do all the heavy lifting — generate the parsing code at compile time, with zero runtime cost and full type safety. The job of the codegen tool is to emit the struct definition and the attribute annotations; serde does the rest.
#[derive(Serialize, Deserialize, Debug)] struct T { … }
Option carries nullability.
Rust has no implicit null. A field that can be missing or null in JSON becomes Option<T> — None when missing, Some(value) when present. The serde default treats missing fields as a deserialization error unless they're Option or annotated with #[serde(default)]. The codegen tool decides: every fieldOption (loose, forgives missing input) or required (strict, fails on unexpected omission). Strict-by-default produces better error messages; loose-by- default is easier to live with for evolving APIs.
rename, rename_all.
Rust's idiomatic field naming is snake_case. Most JSON APIs also use snake_case, so field names just match. But when they don't — JSON in camelCase, kebab-case, or with fields named after Rust reserved words like type — you need #[serde(rename = "name")] or the struct-level #[serde(rename_all = "camelCase")]. Codegen tools emit these automatically when the JSON name and the Rust name diverge.
A worked example.
From { "userId": 42, "name": "Q", "avatar_url": null, "tags": ["x"] }: two fields are camelCase, one is snake_case, one is null. A useful codegen emits a struct with a top-level #[serde(rename_all = "camelCase")] (handles userId implicitly), then explicit #[serde(rename = "avatar_url")] on the snake-cased outlier, and an Option<String> for the null-able avatar field.
Mixed-case JSON input
userId, name, avatar_url, tags
One rename_all attribute plus one specific rename keeps the struct clean.
rename_all camelCase + rename avatar_url
= user_id, name, avatar_url, tags
String vs &str.
Almost every Rust JSON struct should hold owned String values. Borrowed &str is tempting (no allocation) but requires a lifetime parameter and ties the struct's lifetime to the input buffer — which makes it useless for anything beyond the immediate decode. The win from borrowed strings is real for extreme-throughput cases (network proxies, parsers); for normal application code, String is the right default. Codegen tools default to owned for that reason.
Numbers, the i64-vs-u64-vs-f64 question.
JSON numbers are technically arbitrary-precision; in practice they're floats. Serde maps them to whatever Rust integer or float type you declare, and errors at decode time if the value doesn't fit. A field with sample 42 could be i32, i64, u32, u64, f64 — the inferrer picks based on signed-ness, observed magnitude, and a default precision. i64 is the safest default; bump to f64 if you see any decimals.
Enums for tagged unions.
When a JSON array holds shapes with a discriminator field, Rust's enum is a perfect fit. Annotated with #[serde(tag = "type")], an enum like enum Event { Click { x: i32, y: i32 }, Submit { form: String } } parses cleanly from { "type": "Click", "x": 4, "y": 8 }. The codegen tool that recognises this pattern produces dramatically better Rust than one that merges everything into a struct with optional fields.