Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Next

## [0.0.1-alpha.2] - 2026-09-09

* Added the trait `OptionalNullable` to model absent, null, or a value

## [0.0.1-alpha.1] - 2026-08-18

* Initial release
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "json-serde"
version = "0.0.1-alpha.1"
version = "0.0.1-alpha.2"
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"
Expand Down
54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,64 @@ with `#[serde(default)]` it distinguishes absent from `null`. Applied to an

```rust
#[derive(serde::Deserialize, serde::Serialize)]
struct Foo {
struct Data {
/// may be absent, but may not be null
#[serde(
default,
deserialize_with = "::json_serde::deserialize_some",
skip_serializing_if = "Option::is_none",
)]
field: Option<String>,
required_field: Option<String>,

/// distinct states for absent, null, and a value
#[serde(
default,
deserialize_with = "::json_serde::deserialize_some",
skip_serializing_if = "Option::is_none"
)]
tri_state_field: Option<Option<String>>,
}
```

In addition, the `OptionalNullable` trait can be implemented for types that
model this tri-state of absent, null, or a value. It has a default
implementation for `Option<Option<T>>`.

```rust
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum OptionField<T> {
#[default]
#[serde(skip)]
Absent,
Null,
Present(T),
}

impl<T> json_serde::OptionalNullable for OptionField<T> {
type Target = T;

fn is_absent(&self) -> bool {
matches!(self, OptionField::Absent)
}

fn null() -> Self {
Self::Null
}

fn value(value: Self::Target) -> Self {
Self::Present(value)
}
}

#[derive(serde::Deserialize, serde::Serialize)]
struct Data {
/// default constructs an absent value; deserialize accepts a null or value
#[serde(
default,
skip_serializing_if = "::json_serde::OptionalNullable::is_absent"
)]
optional_option: OptionField<String>,
}
```

Expand Down
37 changes: 35 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use serde_core::{
///
/// ```
/// # #[derive(serde::Deserialize, serde::Serialize)]
/// # struct Foo {
/// # struct Data {
/// #[serde(
/// default,
/// deserialize_with = "::json_serde::deserialize_some",
Expand All @@ -37,7 +37,7 @@ use serde_core::{
/// was absent, `null`, or had a value:
/// ```
/// # #[derive(serde::Deserialize, serde::Serialize)]
/// # struct Foo {
/// # struct Data {
/// #[serde(
/// default,
/// deserialize_with = "::json_serde::deserialize_some",
Expand Down Expand Up @@ -434,6 +434,39 @@ impl schemars1::JsonSchema for Absent {
}
}

/// Models a fields that may be absent, null, or a value.
///
/// `Default` is required; it constructs the absent state.
pub trait OptionalNullable: Default {
/// The type for the value when present and non-null.
type Target;

/// Returns true if the instance represents an absent value.
fn is_absent(&self) -> bool;

/// Construct a null.
fn null() -> Self;

/// Construct a value.
fn value(value: Self::Target) -> Self;
}

impl<T> OptionalNullable for Option<Option<T>> {
type Target = T;

fn is_absent(&self) -> bool {
self.is_none()
}

fn null() -> Self {
Some(None)
}

fn value(value: Self::Target) -> Self {
Some(Some(value))
}
}

#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize, ser::SerializeSeq};
Expand Down