· 4 min read
How to Turn JSON Into Rust Structs
Heshan Fernando
Co-founder & COO
You are consuming an API that returns forty fields across three levels of nesting. Writing the structs by hand takes twenty minutes and produces at least one typo that the compiler catches and one Option that it does not.
Generating them from a sample response takes seconds. The interesting question is which parts of the generated output you should not trust.
What the generator can and cannot know
From one JSON sample it can determine the shape — field names, nesting, and the types of the values present.
What it cannot determine is optionality. A field with a value in your sample generates as a plain type. If the API omits that field on some responses, deserialisation fails at runtime with a missing-field error, in production, on the response you did not sample.
That is the single most important thing to check in generated output. The sample tells you what the API did send once; the schema tells you what it may send.
If you have an OpenAPI document, that is the better source. If you only have samples, use the most complete response you can find and then read the generated Option fields critically.
Why the derives matter
#[derive(Serialize, Deserialize)] is what makes serde do anything at all. Beyond that:
Debug — you will want it the first time a response does not parse.
Clone — needed more often than expected, and cheap to add.
Default — useful for building test fixtures, and sometimes needed with #[serde(default)] on optional fields.
Adding #[serde(default)] to a field is worth knowing about: it makes a missing field deserialise to the type’s default rather than failing, which is often what you want for a field that is optional in practice but not typed as Option.
Renaming, and why it matters
Most JSON APIs use camelCase. Rust convention is snake_case. Serde bridges them:
#[serde(rename_all = "camelCase")]
struct User {
user_id: u64,
display_name: String,
}
A container-level rename_all handles the whole struct in one attribute, which is cleaner than a rename on every field. Individual renames are still needed where a JSON key does not map cleanly — a key that is a Rust keyword, or one with a hyphen.
| JSON key | Rust field | Attribute needed |
|---|---|---|
userId | user_id | rename_all = "camelCase" |
type | kind | Field-level rename |
created-at | created_at | Field-level rename |
id | id | None |
Enums beat strings for known values
A field with a fixed set of possible values is better typed as an enum than as a String.
A status field taking “pending”, “active” or “cancelled” generated as a String means every consumer matches on string literals, typos compile fine, and an unexpected value from the API is indistinguishable from an expected one.
As an enum with serde’s rename attributes, the compiler enforces exhaustive handling and an unknown value fails at deserialisation with a clear error rather than propagating.
The complication is API evolution: a strict enum breaks when the API adds a variant. Adding a catch-all Other(String) variant handles that and keeps the type safety for known values, which is usually the right balance.
Common mistakes to avoid
- Trusting the sample’s optionality. A field present once is not a field always present.
- Using
Stringfor every text field when some are enumerated values that would be better as an enum. - Accepting
i64for numbers that are actually identifiers and should be strings — JSON numbers lose precision above 2^53 in some producers. - Generating from a minimal sample where most optional fields are absent, which produces a struct that fails on richer responses.
- Forgetting that a JSON
nulland an absent key are different things, and thatOptionalone does not distinguish them.
How to do it with Rust Struct Generator
The Rust Struct Generator produces serde-ready structs from a sample.
- Paste a representative JSON response — the most complete one you have, not the smallest.
- Choose the derives and whether fields are renamed to snake_case.
- Copy the structs into your crate.
- Review every non-
Optionfield against the API’s documentation before relying on it.
The serde documentation on field attributes covers rename, default and the rest. Other developer tools are in the tools directory.
Frequently asked questions
Why are some fields generated as Option?
Because the sample had null there, or the field was absent. Anything with a value becomes a plain type — which will fail to deserialise the first time the API omits it.
Can one sample produce correct types?
It produces types consistent with that sample. A field that is a string in one response and a number in another will not be caught, and small integers may be typed narrower than the API can actually return.
Should I use rename_all or individual renames?
rename_all at the container level for the common camelCase case, and individual renames only where a key does not map cleanly — a Rust keyword, or a key containing a hyphen.
Final thought
Generate the structs, then read every field that is not an Option and ask whether the API guarantees it. That review is the whole difference between saved time and a runtime failure.