serde-tristate
Three-state field type for HTTP PATCH request bodies, with automatic serde integration.
The problem
HTTP PATCH semantics require distinguishing three states for a field:
| State | JSON | Meaning |
|---|---|---|
Value(T) |
"name": "Alice" |
Set field to this value |
None |
"name": null |
Clear the field |
Undefined |
(absent) | Leave the field unchanged |
Option<T> only covers two of these. Tristate<T> covers all three.
Why not Option<Option<T>>?
Option<Option<T>> can technically encode three states, but falls apart with serde:
// Option<Option<T>> - broken with serde
// `None` and `Some(None)` both deserialize from JSON `null` - indistinguishable
// serde cannot tell "field absent" from "field null" without manual impls
// bio → None ✓
// bio → None ✗ (want Some(None))
serde collapses null → None at the outermost layer, making Some(None) unreachable via derive.
Tristate<T> solves this by deserializing from Option<T> (null → Tristate::None) and using #[serde(default)] for the absent case (missing key → Tristate::Undefined):
// Tristate<T> - correct
// bio → Tristate::Undefined ✓
// bio → Tristate::None ✓
// bio → Tristate::Value(..) ✓
Option<Option<T>> also serializes Some(None) as null and None as absent - which requires #[serde(skip_serializing_if = "Option::is_none")] and still conflates the two null states on the deserialize side.
Usage
Add to Cargo.toml:
[]
= "0.1"
= { = "1", = ["derive"] }
Annotate your struct with #[serde_tristate] and derive Serialize/Deserialize normally - no per-field annotations needed:
use ;
use ;
Serialization
let patch = UpdateUser ;
to_string?;
// {"name":"Alice","age":null}
Deserialization
// absent field → Tristate::Undefined
// null field → Tristate::None
// any value → Tristate::Value(v)
let patch: UpdateUser = from_str?;
// patch.name → Tristate::Value("Bob")
// patch.age → Tristate::Undefined
// patch.bio → Tristate::Undefined
Conversions
let p: = 42.into; // Tristate::Value(42)
let p: = Some.into; // Tristate::Value(42)
let p: = Option::None.into; // Tristate::None
p.into_option; // Undefined→None, None→Some(None), Value(v)→Some(Some(v))
Combinators
patch.name.map;
patch.age.unwrap_or;
patch.bio.and_then;
Applying to an existing entity
let mut user = get_user_from_db;
patch.name.apply_to; // required field
patch.bio.apply_to_option; // Option<T> field
Enums
#[serde_tristate] works on enums with named variant fields too: