background-shape
Serde JSON Patterns, Tagged Enums and the Tricks That Save Time
March 21, 2022 · 6 min read · by Muhammad Amal programming

TL;DR — Serde handles JSON via Serialize/Deserialize derives. Tagged enums for polymorphic payloads. rename_all = "camelCase" for matching JS conventions. #[serde(flatten)] for embedding. Custom (de)serializers for the 5% serde can’t infer. Knowing these few patterns covers 95% of real-world API code.

After Axum and sqlx, the third piece of the backend trio is JSON serialization. serde is the de facto crate. It’s been stable for years, supports a dozen formats, and the derive macro handles most cases without code.

This post is the specific serde patterns I lean on in real backend code — not the fundamentals (well-documented), but the ones nobody warns you about that you’ll discover three days into your first real API.

Setup

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

The basics:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct User {
    id: u64,
    email: String,
    created_at: chrono::DateTime<chrono::Utc>,
}

// Serialize
let json = serde_json::to_string(&user)?;

// Deserialize
let user: User = serde_json::from_str(&json)?;

That’s the entire core API. The patterns below are the variations you’ll need.

Pattern 1 — rename_all for camelCase JSON

Rust uses snake_case. JavaScript APIs use camelCase. Bridge with one attribute:

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct User {
    id: u64,
    email_address: String,        // serializes as "emailAddress"
    created_at: DateTime<Utc>,    // serializes as "createdAt"
}

Other options: kebab-case, snake_case, SCREAMING_SNAKE_CASE, PascalCase, etc. Apply at struct level for all fields, or per-field with #[serde(rename = "myField")].

Pattern 2 — Tagged enums for polymorphic payloads

The single most useful serde feature for real APIs. Three forms:

Externally tagged (default):

#[derive(Serialize, Deserialize)]
enum Event {
    UserCreated { user_id: u64 },
    UserDeleted { user_id: u64 },
}

Serializes as:

{"UserCreated": {"user_id": 42}}

Ugly but unambiguous.

Internally tagged (most common):

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
    UserCreated { user_id: u64 },
    UserDeleted { user_id: u64 },
}

Serializes as:

{"type": "UserCreated", "user_id": 42}

The type field discriminates. Clean for webhook payloads, message queues, etc.

Adjacently tagged:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Event {
    UserCreated { user_id: u64 },
}

Serializes as:

{"type": "UserCreated", "data": {"user_id": 42}}

Useful when payloads are structured and you want a clean split.

For webhook handlers, internally tagged with serde(rename_all = "snake_case") on the enum:

#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WebhookEvent {
    UserCreated { user: User },
    SubscriptionRenewed { subscription: Subscription },
    PaymentFailed { invoice_id: String, reason: String },
}

async fn webhook(Json(event): Json<WebhookEvent>) -> StatusCode {
    match event {
        WebhookEvent::UserCreated { user } => { /* ... */ }
        WebhookEvent::SubscriptionRenewed { subscription } => { /* ... */ }
        WebhookEvent::PaymentFailed { invoice_id, reason } => { /* ... */ }
    }
    StatusCode::OK
}

Type-safe webhook dispatch in five lines.

Pattern 3 — flatten for embedded fields

When a JSON object embeds another object’s fields inline:

#[derive(Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}

#[derive(Deserialize)]
struct ListUsersRequest {
    filter: String,
    #[serde(flatten)]
    pagination: Pagination,
}

Accepts:

{"filter": "active", "page": 1, "per_page": 25}

Useful for reusing a Pagination struct across many request types without nesting.

Pattern 4 — Default values for missing fields

#[derive(Deserialize)]
struct Config {
    name: String,
    #[serde(default)]
    enabled: bool,                              // false if missing
    #[serde(default = "default_timeout")]
    timeout_ms: u64,
}

fn default_timeout() -> u64 { 5000 }

#[serde(default)] calls Default::default() for the field’s type. #[serde(default = "fn")] calls a custom function.

For deserialization where missing = absent vs missing = explicit null:

#[derive(Deserialize)]
struct User {
    email: Option<String>,                     // missing or null → None
    #[serde(default, deserialize_with = "double_option")]
    nickname: Option<Option<String>>,          // missing → None, null → Some(None), value → Some(Some(v))
}

The double-option pattern catches “I want to explicitly set null vs leave alone” semantics — common for PATCH endpoints.

Pattern 5 — Custom serializers for specific fields

When derive isn’t enough — e.g., serializing a timestamp as ISO 8601 string:

use chrono::{DateTime, Utc};
use serde::{Serializer, Deserializer, Deserialize};

fn serialize_iso<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
    s.serialize_str(&dt.to_rfc3339())
}

fn deserialize_iso<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
    let s = String::deserialize(d)?;
    DateTime::parse_from_rfc3339(&s)
        .map_err(serde::de::Error::custom)
        .map(|dt| dt.with_timezone(&Utc))
}

#[derive(Serialize, Deserialize)]
struct Event {
    #[serde(serialize_with = "serialize_iso", deserialize_with = "deserialize_iso")]
    timestamp: DateTime<Utc>,
}

For the common case (RFC 3339 timestamps), the chrono crate’s default Display/FromStr is already RFC 3339-compatible if you enable the serde feature on chrono — usually you don’t need the custom impl.

Pattern 6 — Skip serialization for null/empty

#[derive(Serialize)]
struct User {
    id: u64,
    email: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    nickname: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,
}

Output:

{"id": 1, "email": "a@b.com"}

Cleaner API responses. Without skip, you’d get "nickname": null, "tags": [] cluttering things.

Pattern 7 — JSON values for unknown / dynamic shape

For data you genuinely don’t know the shape of (webhook bodies you’ll inspect at runtime, debug logs), use serde_json::Value:

#[derive(Deserialize)]
struct Webhook {
    event_type: String,
    data: serde_json::Value,
}

if webhook.event_type == "payment.created" {
    let amount = webhook.data["amount"].as_i64().unwrap_or(0);
}

Use sparingly. Reach for typed enums first; Value is the escape hatch for the inherently dynamic.

Pattern 8 — Validation at deserialize time

#[derive(Deserialize)]
#[serde(try_from = "RawUser")]
struct User {
    email: String,
    age: u32,
}

#[derive(Deserialize)]
struct RawUser {
    email: String,
    age: u32,
}

impl TryFrom<RawUser> for User {
    type Error = String;
    fn try_from(raw: RawUser) -> Result<Self, Self::Error> {
        if !raw.email.contains('@') {
            return Err("invalid email".into());
        }
        if raw.age > 130 {
            return Err("invalid age".into());
        }
        Ok(User { email: raw.email, age: raw.age })
    }
}

The try_from attribute runs validation during deserialization. Invalid JSON returns a deserialization error before your handler sees it.

For richer validation (multiple fields, async checks), do it in the handler after deserializing. Don’t over-engineer the serde layer.

Common Pitfalls

Defaulting to Option<T> for every field. Makes the type system useless. Use Option only when “missing” or “null” is meaningfully different from “present.”

Forgetting serde feature flag on chrono / uuid / etc. A struct with a chrono::DateTime field won’t derive Serialize/Deserialize unless chrono’s serde feature is on. Same for uuid. Error messages are unhelpful.

Internally tagged enums with non-struct variants. enum E { A, B { x: i32 } } doesn’t work as internally tagged because A has no place to put a tag. Use externally tagged, or restructure.

Serializing big trees as String first. serde_json::to_string then writing the string is fine for small payloads. For big ones, use serde_json::to_writer to stream directly into the response/file.

Not using Cow<'a, str> for borrowed deserialization. When deserializing JSON that you don’t need to outlive the input, Cow<str> avoids unnecessary allocations. Optimization for hot paths only.

Mixing rename_all and explicit rename. Per-field #[serde(rename = "...")] overrides rename_all. Easy to forget and end up with one camelCase field amid snake_case ones.

Wrapping Up

serde plus eight patterns covers basically every real-world JSON situation in backend Rust. The compile-time guarantees are similar to what sqlx gives for SQL — once the code compiles, the shapes are right. Wednesday: structured logging with tracing — the observability layer that ties Axum + sqlx + serde together with request-scoped context.