Stop Losing Intent: Absent, Null, and Value in Rust

arto1 pts0 comments

Stop Losing Intent: Absent, Null, and Value in Rust> is a trap">> is a trap">> is a trap">

Available<br>Open to new opportunities — if you're building something ambitious let's talk. →<br>When you build production APIs long enough, you learn a painful lesson:<br>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<br>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.<br>Your server deserializes it as None, treats it as “not provided”, and silently ignores the update.<br>User reports: “I can’t delete my bio.” You spend hours debugging,<br>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]<br>presence-rs = "0.1"<br>serde = { version = "1.0", features = ["derive"] }<br>Basic usage:

use presence_rs::Presence;

let absent: PresenceString> = Presence::Absent;<br>let null: PresenceString> = Presence::Null;<br>let value: PresenceString> = Presence::Some("Alice".to_string());

if value.is_some() {<br>println!("We have a value!");<br>The Core Idea

Option has two states. But patch/update models need three.

So Presence 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 makes the semantics unambiguous.

It improves API readability and reviewability

When a reviewer sees:

name: OptionOptionString>><br>they need to stop and decode what the two layers mean.<br>Half the time the meaning isn’t consistent across the codebase.

When they see:

name: PresenceString><br>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> 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? Here’s the comparison:

AspectOption>PresenceReadability Some(None) is ambiguousPresence::Null is explicitSelf-documenting Meaning encoded in positionMeaning encoded in variant namesAccidental collapse Easy with .flatten()Must be explicitMatch ergonomics Nested patterns are awkwardClean, flat variantsCode review clarity Requires comments/contextIntent is immediately clearDomain modeling Generic nestingPurpose-built type<br>Let’s dig into why this matters:

Nesting is not self-documenting

Option> 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: OptionOptionT>> = ...;<br>let y: OptionT> = x.flatten(); // boom: you lost the "null vs absent" distinction<br>Or:

if x.is_none() { /* which "none" did you mean? */ }<br>With Presence, 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 {<br>Presence::Absent => { /* keep */ }<br>Presence::Null => { /* clear */ }<br>Presence::Some(v) => { /* set */ }<br>Now compare it to nested Option:

match update.name {<br>None => { /* ??? */ }<br>Some(None) => { /* ??? */ }<br>Some(Some(v)) => { /* ??? */ }<br>Even if you comment it, the structure fights you.<br>Multiply this across 30 fields and 20 endpoints and it becomes the place where bugs hide.

Your domain model deserves a domain type

Option> is a clever encoding. Presence is a concept.

At a...

null presence absent value none clear

Related Articles