Enums & Tagging
Control how Rust enums map to JSON with serde's tagging attributes - critical for polymorphic APIs and event streams.
Recipe
Quick-reference recipe card - copy-paste ready.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Event {
Click { x: i32, y: i32 },
KeyPress { key: String },
}
// {"type":"Click","data":{"x":1,"y":2}}When to reach for this: APIs carry variant-discriminated messages that JavaScript or other languages must parse reliably.
Working Example
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
enum Message {
Text { body: String },
Image { url: String, width: u32 },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
enum LooseId {
Number(u64),
Text(String),
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", content = "value")]
enum Payment {
Card { last4: String },
Bank { routing: String },
}
fn main() {
let m = Message::Text { body: "hi".into() };
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains(r#""type":"Text""#));
let id_num: LooseId = serde_json::from_str("42").unwrap();
let id_str: LooseId = serde_json::from_str(r#""abc""#).unwrap();
assert!(matches!(id_num, LooseId::Number(42)));
let p = Payment::Card { last4: "4242".into() };
let pjson = serde_json::to_string(&p).unwrap();
assert!(pjson.contains(r#""kind":"Card""#));
}What this demonstrates:
- Internally tagged (
tag = "type"): variant name as discriminator field. - Adjacently tagged (
tag+content): separates kind and payload objects. - Untagged: tries each variant in order until one matches.
Deep Dive
Tagging Styles
| Style | Attribute | JSON shape |
|---|---|---|
| External (default) | none | {"Text":{"body":"hi"}} |
| Internal | tag = "type" | {"type":"Text","body":"hi"} |
| Adjacent | tag + content | {"type":"Text","data":{"body":"hi"}} |
| Untagged | untagged | No wrapper - shape implies variant |
| Flattened | tag + content + flatten fields | Variant-specific |
Rust Notes
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "event")]
enum Audit { UserCreated { id: u64 }, UserDeleted { id: u64 } }Gotchas
- Untagged order matters - Ambiguous shapes deserialize to first matching variant. Fix: Prefer explicit tags for public APIs.
- Variant rename drift - Renaming Rust variants breaks clients. Fix:
#[serde(rename = "legacy_name")]for compatibility. - Unit variants - Externally tagged serialize as strings in some configs. Fix: Snapshot expected JSON per variant.
- Generic enums - Tagging with generics needs careful bounds. Fix: Concrete type aliases at API boundary.
- Internally tagged + tuple variants - Unsupported combination. Fix: Use struct variants or adjacent tagging.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
type field + struct | Manual polymorphism | Many variants with shared tagging rules |
serde_json::Value | Fully dynamic events | You need compile-time safety |
| Protobuf oneof | gRPC services | REST JSON APIs |
FAQs
Which tagging for REST?
Internal or adjacent tags are most JavaScript-friendly.
skip tag field?
#[serde(tag = "t", skip)] advanced - see serde enum docs.
flatten enum in struct?
Combine flatten on struct field holding enum.
unknown variants?
Use #[serde(other)] on fallback variant (serde 1.0+).
deny_unknown_fields on enum?
Apply on struct variants individually.
SCREAMING_SNAKE?
rename_all on enum container for constant-style names.
Round-trip tests?
Test every variant JSON snapshot in CI.
OpenAPI oneOf?
Maps to internally/adjacently tagged enums with code generators.
Version events?
Wrap in Envelope { v: u32, event: Event }.
Binary formats?
Tagging attributes affect MessagePack layout too - test per format.
Related
- Customizing Serialization - Field attributes
- Custom (De)serialization - Manual enum impls
- JSON with serde_json - Examples in JSON
- API Design Basics - API contracts
- serde Best Practices - Forward compatibility
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.