Loading pageā¦
Rust walkthroughs
Loading pageā¦
serde's #[serde(deny_unknown_fields)] interact with flattening nested structures?#[serde(deny_unknown_fields)] causes deserialization to fail when the input contains fields that don't match any struct field. However, when combined with #[serde(flatten)], unknown fields in flattened structures may still be accepted because they're captured by the flattened field itself. A flattened field can receive arbitrary fields from the input, which bypasses the deny_unknown_fields check. This creates a subtle interaction where you might expect strict validation but still accept unknown fields through flattened inner structures.
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
name: String,
version: u32,
}
fn basic_deny() {
// Valid: exact fields match
let valid = r#"{"name": "myapp", "version": 1}"#;
let config: Config = serde_json::from_str(valid).unwrap();
println!("{:?}", config); // Config { name: "myapp", version: 1 }
// Invalid: extra field causes error
let invalid = r#"{"name": "myapp", "version": 1, "extra": true}"#;
let result: Result<Config, _> = serde_json::from_str(invalid);
match result {
Ok(_) => println!("Parsed successfully"),
Err(e) => println!("Error: {}", e),
// Error: unknown field `extra`, expected `name` or `version`
}
}deny_unknown_fields rejects any unrecognized field in the input.
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct Inner {
setting_a: bool,
setting_b: String,
}
#[derive(Debug, Deserialize)]
struct Outer {
name: String,
#[serde(flatten)]
inner: Inner,
}
fn basic_flatten() {
// Flattened structure: Inner fields appear at top level
let json = r#"{
"name": "app",
"setting_a": true,
"setting_b": "value"
}"#;
let config: Outer = serde_json::from_str(json).unwrap();
println!("{:?}", config);
// Outer { name: "app", inner: Inner { setting_a: true, setting_b: "value" } }
// Without flatten, you'd need nested JSON:
// {"name": "app", "inner": {"setting_a": true, "setting_b": "value"}}
}#[serde(flatten)] lifts inner struct fields to the parent level.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Settings {
debug: bool,
timeout: u64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AppConfig {
name: String,
#[serde(flatten)]
settings: Settings,
}
fn interaction_problem() {
// Expected: This should fail because "extra" is unknown
let json = r#"{
"name": "myapp",
"debug": true,
"timeout": 30,
"extra": "unknown field"
}"#;
let result: Result<AppConfig, _> = serde_json::from_str(json);
// SURPRISE: This succeeds!
// The "extra" field is captured by the flattened Settings
// Even though Settings doesn't have an "extra" field,
// flatten allows capturing unknown fields in some cases
match result {
Ok(config) => println!("Parsed: {:?}", config),
Err(e) => println!("Error: {}", e),
}
}Flattened structures can absorb unknown fields, bypassing deny_unknown_fields.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Inner {
a: i32,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Outer {
#[serde(flatten)]
inner: Inner,
}
fn why_it_happens() {
// The flattened Inner can capture fields it doesn't know about
// This is because flatten deserializes into a "catch-all" mode
let json = r#"{"a": 1, "b": 2, "c": 3}"#;
let result: Result<Outer, _> = serde_json::from_str(json);
// Result depends on serde's internal handling:
// - When flattening, serde creates a virtual map for unknown fields
// - The unknown field "b" might be silently ignored
// - Or cause an error depending on exact structure
println!("{:?}", result);
}Flatten creates a virtual map that can absorb unrecognized fields.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictSettings {
debug: bool,
timeout: u64,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
name: String,
#[serde(flatten)]
settings: StrictSettings,
}
fn strict_inner() {
let json = r#"{
"name": "myapp",
"debug": true,
"timeout": 30,
"extra": "unknown"
}"#;
let result: Result<AppConfig, _> = serde_json::from_str(json);
// Even with deny_unknown_fields on the INNER struct,
// the behavior depends on how serde processes the flatten
match result {
Ok(config) => println!("Parsed: {:?}", config),
Err(e) => println!("Error: {}", e),
// May still succeed because outer doesn't deny unknown fields
}
}Putting deny_unknown_fields on the flattened inner struct helps, but isn't always sufficient.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Settings {
debug: bool,
timeout: u64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AppConfig {
name: String,
#[serde(flatten)]
settings: Settings,
}
fn correct_approach() {
// With deny_unknown_fields on the OUTER struct
// Unknown fields at the top level are rejected
let valid = r#"{
"name": "myapp",
"debug": true,
"timeout": 30
}"#;
let config: AppConfig = serde_json::from_str(valid).unwrap();
println!("Valid: {:?}", config);
// But there's a catch with flatten...
let with_extra = r#"{
"name": "myapp",
"debug": true,
"timeout": 30,
"unknown": true
}"#;
// This might still succeed depending on serde version
// because flatten captures remaining fields
}deny_unknown_fields on the outer struct is the intended pattern, but flatten complicates it.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Metadata {
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Document {
title: String,
#[serde(flatten)]
metadata: Metadata,
}
fn demonstrate_behavior() {
// Test 1: Known fields only - works
let json1 = r#"{"title": "Test", "id": "123"}"#;
let doc1: Document = serde_json::from_str(json1).unwrap();
println!("Test 1: {:?}", doc1);
// Test 2: Unknown field at top level
let json2 = r#"{"title": "Test", "id": "123", "extra": true}"#;
let result2: Result<Document, _> = serde_json::from_str(json2);
// This WILL fail because "extra" is unknown to both structs
// and deny_unknown_fields is set on the outer struct
match result2 {
Ok(_) => println!("Test 2: Unexpectedly succeeded"),
Err(e) => println!("Test 2: Error as expected - {}", e),
}
// Test 3: What if inner struct has extra?
// This is where behavior gets nuanced
}Unknown fields not present in any flattened struct will be rejected.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Level2 {
x: i32,
}
#[derive(Debug, Deserialize)]
struct Level1 {
#[serde(flatten)]
level2: Level2,
y: i32,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Root {
#[serde(flatten)]
level1: Level1,
name: String,
}
fn nested_flatten() {
let json = r#"{
"name": "root",
"y": 10,
"x": 20
}"#;
// Fields propagate through nested flattens
let root: Root = serde_json::from_str(json).unwrap();
println!("{:?}", root);
// Unknown fields are checked at each level
let json_with_extra = r#"{
"name": "root",
"y": 10,
"x": 20,
"unknown": 99
}"#;
let result: Result<Root, _> = serde_json::from_str(json_with_extra);
// "unknown" is not in any struct, so it's rejected
}Nested flattens still respect deny_unknown_fields for truly unknown fields.
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
struct Settings {
debug: bool,
}
#[derive(Debug, Deserialize)]
struct Config {
name: String,
#[serde(flatten)]
settings: Settings,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}
fn flatten_catch_all() {
// The HashMap captures any unknown fields
let json = r#"{
"name": "app",
"debug": true,
"custom": "value",
"another": 123
}"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("{:?}", config);
// Config { name: "app", settings: Settings { debug: true },
// extra: {"custom": String("value"), "another": Number(123)} }
// Unknown fields are captured in extra HashMap
// This is often the INTENDED behavior for extensible configs
}A flattened HashMap explicitly captures unknown fields.
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] // This conflicts with flatten catch-all!
struct Config {
name: String,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}
fn conflict() {
let json = r#"{"name": "app", "extra_field": true}"#;
let result: Result<Config, _> = serde_json::from_str(json);
// This creates a confusing situation:
// - deny_unknown_fields says "reject unknown fields"
// - flatten HashMap says "capture unknown fields"
//
// The HashMap will capture "extra_field"
// But deny_unknown_fields might still reject it
// Behavior: serde will reject fields that don't match known fields
// AND aren't captured by flatten
// But fields captured by flatten are NOT considered "unknown"
}deny_unknown_fields combined with a catch-all flatten is usually a design mistake.
use serde::Deserialize;
use std::collections::HashMap;
// Pattern 1: No deny_unknown_fields, explicit catch-all
#[derive(Debug, Deserialize)]
struct FlexibleConfig {
name: String,
version: u32,
#[serde(flatten)]
extensions: HashMap<String, serde_json::Value>,
}
// Pattern 2: Strict with no extensions
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictConfig {
name: String,
version: u32,
}
// Pattern 3: Nested with strict outer and flexible inner
#[derive(Debug, Deserialize)]
struct InnerConfig {
setting_a: bool,
#[serde(flatten)]
extensions: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct OuterConfig {
name: String,
#[serde(flatten)]
inner: InnerConfig,
}
fn patterns() {
// Pattern 1: Accepts any unknown fields
let flex: FlexibleConfig = serde_json::from_str(
r#"{"name": "app", "version": 1, "custom": true}"#
).unwrap();
println!("Flexible: {:?}", flex);
// Pattern 2: Rejects unknown fields
let strict_result: Result<StrictConfig, _> = serde_json::from_str(
r#"{"name": "app", "version": 1, "custom": true}"#
);
assert!(strict_result.is_err());
// Pattern 3: Outer is strict, inner captures extensions
let outer: OuterConfig = serde_json::from_str(
r#"{"name": "app", "setting_a": true, "custom": "value"}"#
).unwrap();
println!("Outer: {:?}", outer);
// Unknown field at outer level is still rejected
let outer_invalid: Result<OuterConfig, _> = serde_json::from_str(
r#"{"name": "app", "setting_a": true, "outer_unknown": 1}"#
);
assert!(outer_invalid.is_err());
}Design your structures with explicit intent about extension points.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
name: String,
#[serde(flatten)]
inner: Inner,
}
#[derive(Debug, Deserialize)]
struct Inner {
value: i32,
}
fn version_behavior() {
// Serde's behavior with flatten + deny_unknown_fields
// has evolved over versions
// In older versions, flatten could silently ignore unknown fields
// In newer versions, the interaction is more predictable:
// - Fields matching flattened struct: accepted
// - Fields matching neither struct: rejected by deny_unknown_fields
let json = r#"{"name": "test", "value": 42, "unknown": true}"#;
let result: Result<Config, _> = serde_json::from_str(json);
match result {
Ok(_) => println!("Parsed (unknown captured or ignored)"),
Err(e) => println!("Rejected: {}", e),
}
}Behavior is well-defined in current serde: fields must match some known field.
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
name: String,
version: u32,
#[serde(flatten)]
metadata: Metadata,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Metadata {
created: String,
}
fn test_cases() {
// Test 1: Valid config
let valid = r#"{"name": "app", "version": 1, "created": "2024-01-01"}"#;
assert!(serde_json::from_str::<Config>(valid).is_ok());
// Test 2: Unknown field at outer level
let unknown_outer = r#"{"name": "app", "version": 1, "created": "2024-01-01", "extra": true}"#;
// Rejected by outer's deny_unknown_fields
assert!(serde_json::from_str::<Config>(unknown_outer).is_err());
// Test 3: Unknown field would go to metadata
let unknown_meta = r#"{"name": "app", "version": 1, "created": "2024-01-01", "meta_extra": true}"#;
// Rejected - "meta_extra" isn't in either struct
assert!(serde_json::from_str::<Config>(unknown_meta).is_err());
println!("All test cases passed expected behavior");
}Write tests to verify your expected strictness behavior.
| Scenario | Result |
|----------|--------|
| deny_unknown_fields without flatten | Rejects any unknown field |
| flatten without deny_unknown_fields | Accepts all fields, captures in flatten target |
| Both deny_unknown_fields and flatten | Rejects fields not in any struct |
| flatten with HashMap | Captures unknown fields (conflicts with deny_unknown_fields) |
| Nested flatten | Fields propagate through levels, checked at each |
The interaction between #[serde(deny_unknown_fields)] and #[serde(flatten)] reveals important design considerations:
Core behavior: deny_unknown_fields rejects fields that don't match any defined field in the struct. When flatten is present, fields matching the flattened struct's fields are acceptedāthey're not "unknown" to the overall structure.
The catch: If you have a flattened struct without deny_unknown_fields, it might accept fields you didn't intend. If you use a flattened HashMap, it explicitly captures unknown fields, making deny_unknown_fields contradictory.
Best practices:
deny_unknown_fields on the outermost struct that should reject unknown fieldsHashMap<String, Value> WITHOUT deny_unknown_fieldsKey insight: deny_unknown_fields checks whether fields are recognized across the entire "flattened surface" of your structure. A field is "known" if it exists in the main struct OR any flattened inner struct. The error message will indicate which fields are expected, including those from flattened structures. The surprising cases arise when developers expect strict checking but inadvertently allow unknown fields through a flattened HashMap or forget that flattened structs contribute their fields to the "known" set.