Stop Losing Intent: Absent, Null, and Value in Rust
When you build production APIs long enough, you learn a painful lesson: most bugs aren’t about values — they’re about intent.
A request payload isn’t just “data”. It’s a command:
- “Don’t touch this field.”
- “Clear this field.”
- “Set this field to X.”
JSON gives you three distinct signals for that:
- Missing field (
{}) → no opinion / leave unchanged - Explicit
null({"name": null}) → clear the value - Concrete value (
{"name": "Alice"}) → set it
If you collapse these states, you will eventually ship a bug that overwrites or clears data incorrectly.It may be rare. It will be expensive.
Real example: A mobile app sends {"bio": null} to clear a user’s bio.
Your server deserializes it as None, treats it as “not provided”, and silently ignores the update.
User reports: “I can’t delete my bio.” You spend hours debugging,
only to find the issue isn’t in the app or the database - it’s in how you modeled the request.
That’s what presence-rs exists for: representing “missing vs null vs present” explicitly in the type system.
Repository: https://github.com/minikin/presence-rs
Getting Started
Add presence-rs to your project:
[dependencies]
presence-rs = "0.1"
serde = { version = "1.0", features = ["derive"] }
Basic usage:
use presence_rs::Presence;
let absent: Presence<String> = Presence::Absent;
let null: Presence<String> = Presence::Null;
let value: Presence<String> = Presence::Some("Alice".to_string());
if value.is_some() {
println!("We have a value!");
}
The Core Idea
Option<T> has two states. But patch/update models need three.
So Presence<T> makes intent first-class:
Presence::Absent→ not providedPresence::Null→ explicitly nullPresence::Some(T)→ provided value
This is the whole point: you can’t ignore the distinction anymore. The compiler forces you to decide.
Why This Matters
Data safety: fewer accidental destructive updates
If you treat “missing” and “null” the same, you can’t implement the common rules safely:
- Missing: keep existing value
- Null: clear it
- Value: set it
People think they handle this with convention, but conventions leak:
- client libraries differ
- “frontend sends null sometimes” becomes a thing
- one endpoint treats null as “clear”, another treats it as “ignore”
- migrations and refactors regress behavior
Presence<T> makes the semantics unambiguous.
It improves API readability and reviewability
When a reviewer sees:
name: Option<Option<String>>
they need to stop and decode what the two layers mean. Half the time the meaning isn’t consistent across the codebase.
When they see:
name: Presence<String>
the meaning is clear immediately: you’re modeling presence, not optionality.
It scales across systems
This tri-state shows up in:
- PATCH endpoints
- GraphQL-style “undefined vs null”
- SQL “not provided vs set NULL”
- config overlays / layered settings
- partial updates in event sourcing / CQRS
Once you have the right abstraction, you stop rewriting the same fragile glue.
”But Option<Option<T>> Already Gives Three States” — Why Not Use It?”
You’re right: it can represent three states:
None→ absentSome(None)→ nullSome(Some(v))→ present
So why introduce Presence<T>? Here’s the comparison:
| Aspect | Option<Option<T>> | Presence<T> |
|---|---|---|
| Readability | Some(None) is ambiguous | Presence::Null is explicit |
| Self-documenting | Meaning encoded in position | Meaning encoded in variant names |
| Accidental collapse | Easy with .flatten() | Must be explicit |
| Match ergonomics | Nested patterns are awkward | Clean, flat variants |
| Code review clarity | Requires comments/context | Intent is immediately clear |
| Domain modeling | Generic nesting | Purpose-built type |
Let’s dig into why this matters:
Nesting is not self-documenting
Option<Option<T>> encodes meaning in position, not in names. You constantly ask:
- “Is outer
Noneabsent or null?” - “Is inner
Noneabsent or null?” - “Which layer do we use for ‘clear’ in this endpoint?”
With Presence, the names carry intent: Absent, Null, Some.
It’s easy to accidentally collapse states
Common patterns silently destroy semantics:
let x: Option<Option<T>> = ...;
let y: Option<T> = x.flatten(); // boom: you lost the "null vs absent" distinction
Or:
if x.is_none() { /* which "none" did you mean? */ }
With Presence<T>, you can still choose to collapse states — but you have to do it deliberately.
Ergonomics and correctness in business logic
Real code needs readable branching:
match update.name {
Presence::Absent => { /* keep */ }
Presence::Null => { /* clear */ }
Presence::Some(v) => { /* set */ }
}
Now compare it to nested Option:
match update.name {
None => { /* ??? */ }
Some(None) => { /* ??? */ }
Some(Some(v)) => { /* ??? */ }
}
Even if you comment it, the structure fights you. Multiply this across 30 fields and 20 endpoints and it becomes the place where bugs hide.
Your domain model deserves a domain type
Option<Option<T>> is a clever encoding. Presence<T> is a concept.
At a fundamental level, this is the real argument: encode domain semantics in types, not in conventions. Because conventions don’t compose, and they don’t survive teams.
A Concrete Example: PATCH Update Done Right
Let’s model the typical update rule:
- Absent → no change
- Null → clear
- Some(v) → set v
use presence_rs::Presence;
use serde::{Deserialize, Serialize};
// Your existing domain model - fields are Option<T> because they're nullable
#[derive(Debug)]
struct User {
id: String,
name: Option<String>,
email: Option<String>,
bio: Option<String>,
}
// The patch request - fields are Presence<T> to distinguish absent/null/value
#[derive(Debug, Deserialize)]
struct UserPatch {
#[serde(default)] // Missing fields deserialize as Presence::Absent
name: Presence<String>,
#[serde(default)]
email: Presence<String>,
#[serde(default)]
bio: Presence<String>,
}
// Reusable helper that encodes your update semantics once
fn apply_field<T>(target: &mut Option<T>, update: Presence<T>) {
match update {
Presence::Absent => {}, // Field missing from JSON → no change
Presence::Null => *target = None, // Field is null in JSON → clear
Presence::Some(v) => *target = Some(v), // Field has value → set it
}
}
fn apply_patch(user: &mut User, patch: UserPatch) {
apply_field(&mut user.name, patch.name);
apply_field(&mut user.email, patch.email);
apply_field(&mut user.bio, patch.bio);
}
What this gives you:
{}→ no fields change{"name": null}→ clears name, leaves email and bio alone{"name": "Alice", "bio": null}→ sets name, clears bio, leaves email alone
This is exactly the kind of code that stays correct during refactors because it’s explicit.
Ecosystem Integration
Presence<T> works seamlessly with the Rust ecosystem you’re already using:
Serde (JSON/API serialization):
#[derive(Deserialize)]
struct UpdateRequest {
#[serde(default)] // Absent if not in JSON
field: Presence<String>,
}
Web frameworks (Axum, Actix, Rocket): Works out of the box with any framework that uses serde for request deserialization.
Database operations (sqlx, diesel):
Map Presence<T> to your SQL update strategy:
Absent→ skip field in UPDATE statementNull→SET field = NULLSome(v)→SET field = $1
GraphQL: Naturally models the GraphQL distinction between undefined (absent) and null.
This isn’t a new pattern you need to teach your stack, it’s a formalization of what you’re already doing ad-hoc.
Practical Tips for Rolling This Out
A small type is easy to adopt, but consistency matters. A few battle-tested rules:
- Write down semantics per field: is
Nullallowed? what does it mean? - Reject ambiguous payloads early: if “clear” isn’t supported, return a 4xx on
Null. - Prefer explicit conversions at boundaries:
- decode request →
Presence<T> - map to domain update intent
- apply update in one place
- decode request →
The goal is to make “what happens on Absent/Null/Value” obvious and testable.
When NOT to Use Presence<T>
Like any abstraction, Presence<T> has its place. Consider simpler approaches when:
Full replacement updates:
If your API always replaces the entire resource (PUT semantics), standard Option<T> is sufficient.
You don’t need tri-state when every update is total.
Internal-only APIs: If you control both client and server tightly (monolith, microservices with shared types), conventions might be enough. The type system shines most at boundaries you don’t control.
No-op semantics: If your domain genuinely treats “missing” and “null” identically, don’t introduce artificial distinction. Use the simplest model that matches your business rules.
High-throughput, low-ambiguity scenarios: If you’re processing millions of updates per second and the absent/null distinction never matters in practice, the extra clarity might not justify the mental overhead.
The rule: use Presence<T> when the cost of getting absent/null wrong exceeds the cost of being explicit.
For PATCH endpoints, user-facing APIs, and multi-team systems, that bar is usually low.
Conclusion
Presence<T> is deliberately small: it takes a common footgun—“missing vs
null”—and turns it into an explicit, readable, matchable type.
You can still write bad logic with Presence<T>, sure — but you can’t accidentally forget there are three states.
That’s the win.
If your system has patch semantics, schema-driven models, or any API where “not provided” is different from “clear it”, encoding this in the type system is one of those changes that turns “it might work” into “it will work”—because the compiler forces you to handle intent, not just values.
Try it:
cargo add presence-rs
Check out the repository for examples, documentation, and contributions.