Controlling JSON shape: serde attributes
Rust fields are snake_case, but most JSON APIs (and JavaScript clients) use
camelCase. serde's #[serde(...)] attributes bridge that gap — and several others —
without writing any manual mapping code. These are the everyday attributes you'll reach
for on real API types.
rename_all: the camelCase ↔ snake_case papercut
Put #[serde(rename_all = "camelCase")] on the type and serde maps every field for you:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Product {
id: u32,
display_name: String, // <-> JSON "displayName"
in_stock: bool, // <-> JSON "inStock"
}Now display_name serializes to "displayName" and parses back from it, while your Rust
code keeps idiomatic snake_case. Other values: "PascalCase", "kebab-case",
"SCREAMING_SNAKE_CASE".
In C#: this is JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }
(System.Text.Json) or a camelCase contract resolver (Newtonsoft) — except in Rust it's a
per-type attribute rather than a global serializer setting.
rename: one field with a different name
For a single field (or to match a name that isn't a clean case-conversion):
#[derive(Serialize, Deserialize)]
struct Account {
#[serde(rename = "type")]
kind: String, // "type" is a Rust keyword; expose it as `kind` in code
}In C#: exactly [JsonPropertyName("type")].
Option<T> is nullable; skip_serializing_if omits it
An Option<T> field accepts null or a missing key on the way in, and you can choose to
omit it entirely on the way out:
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Patch {
#[serde(skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
}With display_name: None, the output is {} — not {"displayName":null}. Useful for PATCH
payloads and compact responses.
In C#: Option<T> ≈ a nullable property; skip_serializing_if = "Option::is_none" ≈
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)].
default: tolerate missing fields
If a field may be absent in the input, #[serde(default)] fills it from Default instead
of failing:
#[derive(Deserialize)]
struct Config {
#[serde(default)] // missing -> 0
retries: u32,
#[serde(default = "default_timeout")] // missing -> 30
timeout_secs: u32,
}
fn default_timeout() -> u32 { 30 }In C#: like an optional property that keeps its default when the JSON omits it.
Dynamic JSON: serde_json::Value
When you don't have (or want) a struct, parse into serde_json::Value and index or match:
use serde_json::Value;
let v: Value = serde_json::from_str(r#"{"name":"Alice","tags":["a","b"]}"#)?;
// Indexing returns Value::Null for missing keys (doesn't panic on objects):
let name = v["name"].as_str().unwrap_or("?");
let first_tag = v["tags"][0].as_str();
match &v["tags"] {
Value::Array(items) => println!("{} tags", items.len()),
_ => println!("no tags"),
}In C#: Value is JsonElement (System.Text.Json) or JObject/JArray (Newtonsoft) —
a tree you navigate when the shape is dynamic.
Enums as tagged JSON (discriminated unions)
By default a Rust enum serializes "externally tagged"; for the common API shape with a
discriminator field, use #[serde(tag = "...")]:
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum Event {
Click { x: i32, y: i32 }, // {"type":"click","x":1,"y":2}
KeyPress { key: String }, // {"type":"keyPress","key":"a"}
}Enum-level rename_all renames the variant tags ("click", "keyPress"); to also rename
the fields inside a struct variant, add a #[serde(rename_all = "camelCase")] on that
variant too.
#[serde(untagged)] instead tries each variant in order until one fits (no discriminator).
In C#: tagged enums are polymorphic JSON — [JsonPolymorphic] + [JsonDerivedType] in
System.Text.Json (.NET 7+), or TypeNameHandling in Newtonsoft.
Key takeaway
Shape JSON declaratively with serde attributes: #[serde(rename_all = "camelCase")] for the
naming-convention gap, #[serde(rename)] for one field, Option<T> +
skip_serializing_if = "Option::is_none" for nullable/omitted fields, #[serde(default)]
for missing input, serde_json::Value for dynamic JSON, and #[serde(tag = "...")] for
discriminated-union enums. They replace the per-property attributes and serializer options
you'd configure in C#.