Skip to main content

From JSON to Rust Structs in One Paste: A serde Field Guide

Turn any JSON payload into Rust structs with serde Deserialize derives, Option for nullable fields, Vec for arrays, and nested sub-structs — no hand-typing.

Published By Li Lei
#rust #serde #json #developer-tools

From JSON to Rust Structs in One Paste: A serde Field Guide

Every Rust developer who talks to an HTTP API hits the same wall. You have a JSON response in front of you, you want a real typed struct to deserialize it into, and writing that struct by hand for a thirty-field GitHub user object is forty minutes of squinting at curly braces. Miss one nested field, get one type wrong, and serde_json::from_str hands you a runtime error instead of a compile error — which defeats the entire reason you reached for Rust.

This post walks through how I turn JSON into idiomatic serde structs, what the type inference rules actually are, and where the sharp edges live. If you just want the result, paste your payload into the JSON to Rust struct generator and read on to understand what it produced.

Why hand-writing serde structs is the wrong default

The naive shortcut is to deserialize everything into serde_json::Value and reach in with data["user"]["id"].as_i64().unwrap(). It compiles, it runs, and it quietly ships a landmine. Every .unwrap() is a panic waiting for the one production payload where id is missing or a string. You gave up the type system the moment you typed Value.

The correct default is a named struct per JSON object shape, with #[derive(Deserialize)] doing the parsing. Once that exists, the compiler checks every field access, your IDE autocompletes the field names, and a malformed response fails loudly at the deserialize boundary instead of three call sites later. The only reason people skip it is that writing the structs by hand is tedious — which is exactly the part worth automating.

The serde derive, line by line

Here is the contract every generated struct honors. A field like this:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubUser {
    pub login: String,
    pub id: i64,
    #[serde(rename = "html_url")]
    pub html_url: String,
    pub plan: Option<GitHubUserPlan>,
}

#[derive(Deserialize)] is the line that earns its keep. It expands at compile time into a from_str implementation that reads the JSON key by key, matches it to a field, and converts the value to the field's Rust type. Pair it with Serialize and the same struct round-trips back to JSON without you writing a single mapping function. To compile, you need this in Cargo.toml:

serde = { version = "1", features = ["derive"] }
serde_json = "1"

The derive feature is the one people forget. Without it the attribute does nothing and the build fails with a confusing trait error.

Worked example: a GitHub-style user

Drop this JSON in:

{
  "login": "octocat",
  "id": 583231,
  "site_admin": false,
  "createdAt": "2011-01-25T18:44:36Z",
  "bio": null,
  "plan": { "name": "pro", "seats": 5 }
}

You get back two structs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
    pub login: String,
    pub id: i64,
    pub site_admin: bool,
    #[serde(rename = "createdAt")]
    pub created_at: String,
    pub bio: Option<serde_json::Value>,
    pub plan: RootPlan,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootPlan {
    pub name: String,
    pub seats: i64,
}

Three things happened automatically. The nested plan object lifted out into its own RootPlan struct instead of being inlined or flattened into a map. The wire key createdAt became the snake_case field created_at with a #[serde(rename = "createdAt")] attribute, so the Rust reads naturally while serde still speaks camelCase on the wire — and clippy never complains about non_snake_case. And site_admin, which was already snake_case, got no rename at all, because adding one would be noise.

Option, Vec, and the nested-struct rules

Three inference rules cover almost everything you'll meet.

Nullable fields become Option<T>. A field gets wrapped in Option when it appears as null in your JSON, or when it's present in some elements of an object-array but missing from others. That's the idiomatic Rust nullable: serde deserializes a missing-or-null value to None instead of erroring. The webhook case is the classic one — a vendor sends cancelled_at on cancelled events and omits it otherwise. Paste a few payloads as a JSON array and cancelled_at comes back as Option<String>, so match event.cancelled_at { Some(ts) => ..., None => ... } distinguishes the two states cleanly. A field that's only ever null becomes Option<serde_json::Value>, because null carries no type information and guessing would be a lie.

Arrays become Vec<T>. An array of uniform scalars types precisely — [1, 2, 3] is Vec<i64>, ["a", "b"] is Vec<String>. An array of objects folds into one named struct, so [{"a":1},{"a":1,"b":2}] gives you a single RootItem type carrying both fields (with b as Option, since it's missing from the first element) and a Vec<RootItem> — not two near-duplicate structs. A mixed array like [1, "two", true] widens to Vec<serde_json::Value> because there's no single element type, and an empty [] does the same since there's nothing to infer from.

Nested objects become sub-structs. Every object value gets its own named type, derived from the parent name plus the key. This keeps the output flat and readable instead of one monstrous nested generic, and it means each level is independently usable across your modules.

On numbers: JSON has one number type with no sign or width hint, so integers infer as i64 and decimals as f64. That's deliberate. i64 holds the millisecond timestamps and snowflake IDs that overflow i32, and it never rejects a negative value the way u64 would. If you know a field is small or unsigned, narrow it by hand after pasting — but i64/f64 is the default that always compiles and always parses.

How I actually use it day to day

My honest workflow is unglamorous. When I'm integrating a new endpoint, I curl it once into a scratch file, eyeball the response, and if it's a tangle of camelCase and nested objects I don't even try to hand-write the type. I paste the raw response, set the root name to something meaningful like StripeCheckoutSession, flip on #[serde(default)] so a partial payload still deserializes, and copy the output straight into a module. The first time I trusted it fully was a Stripe webhook with about sixty fields across four nesting levels — the part that would have eaten an afternoon and shipped at least one wrong Option took under a minute, and it compiled on the first cargo build. The only edits I make afterward are narrowing the occasional i64 to u32 where I know the domain, and that's a deliberate ten-second decision rather than sixty fields of transcription.

One habit worth keeping: if your JSON came out of a log line or a copy-paste that might have stray commas or comments, run it through the JSON formatter and validator first. The generator uses strict JSON.parse, so trailing commas and // comments error out rather than being silently tolerated — cleaning the input first saves a confusing failure.

Other targets from the same JSON

The same approach maps cleanly onto other typed languages. If your service spans a TypeScript frontend and a Rust backend reading the same payloads, the JSON to TypeScript interface generator gives you the matching interface declarations so both sides agree on the shape — a null becomes T | null there the way it becomes Option<T> here. Generating both from one captured response is the cheapest way I know to keep a cross-language contract honest.

Everything runs in your browser. The JSON never touches a server, so pasting a production response to get its type is safe — copy the generated Rust instead of sharing the link if the payload itself is sensitive.


Made by Toolora · Updated 2026-06-13