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 provided
  • Presence::Null → explicitly null
  • Presence::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 → absent
  • Some(None) → null
  • Some(Some(v)) → present

So why introduce Presence<T>? Here’s the comparison:

AspectOption<Option<T>>Presence<T>
ReadabilitySome(None) is ambiguousPresence::Null is explicit
Self-documentingMeaning encoded in positionMeaning encoded in variant names
Accidental collapseEasy with .flatten()Must be explicit
Match ergonomicsNested patterns are awkwardClean, flat variants
Code review clarityRequires comments/contextIntent is immediately clear
Domain modelingGeneric nestingPurpose-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 None absent or null?”
  • “Is inner None absent 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 statement
  • NullSET field = NULL
  • Some(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 Null allowed? 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

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.