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.