How does serde's #[serde(tag = "type")] enable internally tagged enums for serialization?
#[serde(tag = "type")] configures an enum to serialize with a type discriminator field embedded inside the variant's data structure rather than wrapping it externally. This creates a flatter JSON representation where the tag field appears alongside variant data fields, matching common API designs that use a "type" field for variant identification. Without tag, serde uses externally tagged representation where the variant name becomes a key wrapping the data. Internally tagged enums work best when all variants share a similar structure or when integrating with APIs that follow this discriminator pattern, but they require all variants to be structs with named fields and cannot represent unit or newtype variants in the same way.
Externally Tagged Enums (Default)
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
enum Event {
Click { x: i32, y: i32 },
KeyPress { key: String },
MouseMove { x: i32, y: i32 },
}
fn main() {
let event = Event::Click { x: 100, y: 200 };
let json = serde_json::to_string(&event).unwrap();
println!("{}", json);
// {"Click":{"x":100,"y":200}}
let key_event = Event::KeyPress { key: "Enter".to_string() };
let json = serde_json::to_string(&key_event).unwrap();
println!("{}", json);
// {"KeyPress":{"key":"Enter"}}
}By default, serde uses external tagging: the variant name wraps the variant data.
Internally Tagged Enums
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
Click { x: i32, y: i32 },
KeyPress { key: String },
MouseMove { x: i32, y: i32 },
}
fn main() {
let event = Event::Click { x: 100, y: 200 };
let json = serde_json::to_string(&event).unwrap();
println!("{}", json);
// {"type":"Click","x":100,"y":200}
let key_event = Event::KeyPress { key: "Enter".to_string() };
let json = serde_json::to_string(&key_event).unwrap();
println!("{}", json);
// {"type":"KeyPress","key":"Enter"}
}With tag = "type", the variant name becomes a field inside the object.
Deserialization with Internally Tagged
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
fn main() {
let json = r#"{"type":"Circle","radius":5.0}"#;
let shape: Shape = serde_json::from_str(json).unwrap();
println!("{:?}", shape);
// Shape::Circle { radius: 5.0 }
let json = r#"{"type":"Rectangle","width":10.0,"height":20.0}"#;
let shape: Shape = serde_json::from_str(json).unwrap();
println!("{:?}", shape);
// Shape::Rectangle { width: 10.0, height: 20.0 }
// Deserialize directly - serde looks at "type" to determine variant
let json = r#"{"type":"Triangle","base":3.0,"height":4.0}"#;
let shape: Shape = serde_json::from_str(json).unwrap();
println!("{:?}", shape);
}Serde uses the tag field to determine which variant to deserialize.
The Field Name Restriction
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Message {
Request { id: u32, method: String },
Response { id: u32, status: String },
}
fn main() {
// This works - both variants have "id" field
let json = r#"{"type":"Request","id":1,"method":"get"}"#;
let msg: Message = serde_json::from_str(json).unwrap();
println!("{:?}", msg);
// Tag field name must not conflict with variant fields
// If a variant had a "type" field, it would conflict:
// #[derive(Serialize, Deserialize)]
// #[serde(tag = "type")]
// enum Bad {
// Variant { type: String }, // Error: conflicts with tag!
// }
}The tag field name must not conflict with any variant's field names.
Adjacently Tagged Enums Comparison
use serde::{Deserialize, Serialize};
// Internally tagged - tag inside the object
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Internal {
Variant { data: String },
}
// Adjacently tagged - tag and data as siblings
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Adjacent {
Variant(String),
}
fn main() {
// Internal: {"type":"Variant","data":"hello"}
let internal = Internal::Variant { data: "hello".to_string() };
println!("Internal: {}", serde_json::to_string(&internal).unwrap());
// Adjacent: {"type":"Variant","data":"hello"}
let adjacent = Adjacent::Variant("hello".to_string());
println!("Adjacent: {}", serde_json::to_string(&adjacent).unwrap());
// Both look similar but handle variants differently
// Adjacent works with tuple variants; internal requires struct variants
}Adjacent tagging works with any variant type; internal requires struct variants.
Unit Variants with Internally Tagged
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Status {
Pending,
Active,
Completed,
}
fn main() {
let status = Status::Active;
let json = serde_json::to_string(&status).unwrap();
println!("{}", json);
// {"type":"Active"}
// Unit variants work as a special case
// They serialize as just the tag field
}Unit variants serialize to just the tag field with internally tagged representation.
Newtype Variants Require Different Handling
use serde::{Deserialize, Serialize};
// This WON'T compile:
// #[derive(Debug, Serialize, Deserialize)]
// #[serde(tag = "type")]
// enum NewtypeEnum {
// Value(String), // Error: internally tagged enums cannot have newtype variants
// }
// Use adjacently tagged instead:
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
enum NewtypeEnum {
Value(String),
}
fn main() {
let v = NewtypeEnum::Value("hello".to_string());
let json = serde_json::to_string(&v).unwrap();
println!("{}", json);
// {"type":"Value","value":"hello"}
}Internally tagged enums cannot have newtype or tuple variants directly.
Struct Variant Requirement
use serde::{Deserialize, Serialize};
// Internally tagged requires struct variants (named fields)
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind")]
enum Animal {
Dog { name: String, breed: String },
Cat { name: String, lives: u8 },
Bird { name: String, can_fly: bool },
}
// This structure enables the tag to be inserted alongside fields
// Without named fields, there's nowhere to put the tag
fn main() {
let dog = Animal::Dog {
name: "Fido".to_string(),
breed: "Labrador".to_string(),
};
let json = serde_json::to_string(&dog).unwrap();
println!("{}", json);
// {"kind":"Dog","name":"Fido","breed":"Labrador"}
}Only struct variants (with named fields) work with internal tagging.
Custom Tag Field Names
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "@type")] // Common in JSON-LD and some APIs
enum ApiObject {
Person { name: String, age: u32 },
Organization { name: String, employees: u32 },
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "event_type")]
enum LogEvent {
Login { user_id: u32 },
Logout { user_id: u32, duration_secs: u64 },
}
fn main() {
let person = ApiObject::Person {
name: "Alice".to_string(),
age: 30,
};
println!("{}", serde_json::to_string(&person).unwrap());
// {"@type":"Person","name":"Alice","age":30}
let event = LogEvent::Login { user_id: 42 };
println!("{}", serde_json::to_string(&event).unwrap());
// {"event_type":"Login","user_id":42}
}Choose tag field names that match your API's expectations.
Deserializing Unknown Variants
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Config {
Database { url: String },
Cache { ttl: u64 },
}
fn main() {
// Valid variant
let json = r#"{"type":"Database","url":"postgres://localhost"}"#;
let config: Result<Config, _> = serde_json::from_str(json);
println!("{:?}", config);
// Unknown variant
let json = r#"{"type":"Unknown","data":"value"}"#;
let config: Result<Config, _> = serde_json::from_str(json);
println!("{:?}", config);
// Err: unknown variant `Unknown`, expected `Database` or `Cache`
}Unknown variants produce clear error messages referencing expected variants.
Nested Enums with Internal Tagging
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Outer {
Inner(InnerEnum), // This requires adjacent or external tagging for InnerEnum
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "category")]
enum InnerEnum {
A { value: i32 },
B { value: String },
}
// Better approach: avoid nesting, flatten structure
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum FlatEvent {
OuterA { inner: i32 },
OuterB { inner: String },
}
fn main() {
// Nested enums work but may have confusing JSON structure
let outer = Outer::Inner(InnerEnum::A { value: 42 });
let json = serde_json::to_string(&outer).unwrap();
println!("{}", json);
// {"type":"Inner","category":"A","value":42}
}Nested internally tagged enums work but produce nested tag fields.
Combining with Other Attributes
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event {
UserCreated { user_id: u32, email: String },
UserDeleted { user_id: u32 },
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum ApiResponse {
Success {
#[serde(rename = "data")]
result: String,
},
Error {
#[serde(rename = "error_message")]
message: String,
},
}
fn main() {
let event = Event::UserCreated {
user_id: 1,
email: "test@example.com".to_string(),
};
println!("{}", serde_json::to_string(&event).unwrap());
// {"type":"user_created","user_id":1,"email":"test@example.com"}
}Combine tag with rename_all and other attributes for API compatibility.
Flattening with Tagged Enums
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum EventType {
Click { x: i32, y: i32 },
Scroll { delta: i32 },
}
#[derive(Debug, Serialize, Deserialize)]
struct Event {
timestamp: u64,
#[serde(flatten)]
event_type: EventType,
}
fn main() {
let event = Event {
timestamp: 1234567890,
event_type: EventType::Click { x: 100, y: 200 },
};
let json = serde_json::to_string(&event).unwrap();
println!("{}", json);
// {"timestamp":1234567890,"type":"Click","x":100,"y":200}
// The type field from EventType is flattened into the parent
}Flattening preserves the tag field, merging it into the parent structure.
Matching API Patterns
use serde::{Deserialize, Serialize};
// Many APIs use a discriminator field pattern
// This is exactly what internally tagged enums match
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind")]
enum KubernetesResource {
Pod {
name: String,
namespace: String,
},
Service {
name: String,
port: u16,
},
Deployment {
name: String,
replicas: u32,
},
}
// GitHub API-style event
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "action")]
enum GitHubEvent {
Pushed { ref: String, commits: u32 },
Opened { number: u32, title: String },
Closed { number: u32, merged: bool },
}
fn main() {
// Kubernetes-style JSON
let json = r#"{"kind":"Pod","name":"my-pod","namespace":"default"}"#;
let resource: KubernetesResource = serde_json::from_str(json).unwrap();
println!("{:?}", resource);
}Many real-world APIs use this discriminator pattern naturally.
Handling Optional Fields with Tags
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Message {
Request {
id: u32,
#[serde(skip_serializing_if = "Option::is_none")]
payload: Option<String>,
},
Response {
id: u32,
result: String,
},
}
fn main() {
let msg = Message::Request {
id: 1,
payload: None,
};
println!("{}", serde_json::to_string(&msg).unwrap());
// {"type":"Request","id":1}
let msg = Message::Request {
id: 2,
payload: Some("data".to_string()),
};
println!("{}", serde_json::to_string(&msg).unwrap());
// {"type":"Request","id":2,"payload":"data"}
}All serde attributes work normally with internally tagged enums.
Error Handling and Validation
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Command {
Create { name: String },
Delete { id: u32 },
Update { id: u32, name: String },
}
fn parse_command(json: &str) -> Result<Command, serde_json::Error> {
serde_json::from_str(json)
}
fn main() {
// Missing tag
let result = parse_command(r#"{"name":"test"}"#);
match result {
Err(e) => println!("Error: {}", e),
// Error: missing field `type` at line 1 column 16
_ => {}
}
// Valid
let cmd = parse_command(r#"{"type":"Create","name":"test"}"#);
println!("{:?}", cmd);
// Extra fields are accepted by default
let cmd = parse_command(r#"{"type":"Create","name":"test","extra":true}"#);
println!("{:?}", cmd);
// Ok(Create { name: "test" })
}The tag field is required for deserialization; extra fields are ignored by default.
Summary Table
| Tagging Style | JSON Structure | Variant Types Supported |
|---|---|---|
| External (default) | {"Variant": {...}} |
All |
Internal (tag) |
{"type":"Variant",...} |
Struct and unit only |
Adjacent (tag+content) |
{"type":"Variant","content":...} |
All |
| Untagged | {...} (no discriminator) |
All (requires unique fields) |
Synthesis
#[serde(tag = "type")] enables internally tagged enums by placing the variant discriminator as a field within the serialized object:
How it works: During serialization, serde adds the tag field (e.g., "type": "Click") alongside the variant's fields. During deserialization, serde reads the tag field first to determine which variant to deserialize into, then parses the remaining fields for that variant.
The constraint: Only struct variants (with named fields) and unit variants work. Newtype and tuple variants cannot be internally tagged because there's no structure to insert the tag intoâyou'd need adjacent tagging (#[serde(tag = "...", content = "...")]) for those.
When to use it:
- APIs that use a discriminator field pattern (very common in REST APIs, JSON schemas)
- When you want flatter JSON without nested variant objects
- When deserializing from formats where the type is determined by a field
- Polymorphic data structures where the type is embedded in the data
When to avoid it:
- When you need tuple or newtype variants (use adjacent tagging)
- When the tag field name conflicts with data fields
- When external wrapping is the desired format
Key insight: Internally tagged enums match how many real-world APIs structure their data. The discriminator field patternâwhere a type, kind, or @type field identifies the variantâis ubiquitous in JSON APIs, making serde(tag = "...") the natural choice for interoperating with such APIs. The representation produces cleaner, flatter JSON that matches what many APIs already use, eliminating the need for custom serialization logic to reshape the data.
