Jackson Undefined Property Module is a Java and Kotlin extension for the Jackson serialization framework that enables clear differentiation between:
- Undefined (absent) values: Values that were never specified and should not be included in serialization.
- Explicitly null values: Values explicitly set to
null
, meaning they should be serialized asnull
. - Concrete values: Actual values provided in the object.
This distinction is particularly useful in scenarios like PATCH requests, where the absence of a field should not
override existing values, but explicitly setting null
should (Yes, I'm looking at you JavaScript!).
- Automatic handling of undefined vs. null vs. concrete values
- Custom serialization and deserialization via Jackson modules, no black magic
- Seamless integration with Java and Kotlin
- Supports both immutable and mutable data models
- Works without modifying existing Jackson configurations at the ObjectMapper level
- Seamless convert between
Optional<T>
andProperty<T>
- Uses JSpecify for enhanced nullability annotations
Standard Jackson behavior does not differentiate between missing and explicitly null values. This module enhances Jackson’s ability to:
- Omit undefined values from serialization.
- Retain explicit nulls when necessary.
- Enable fine-grained control over PATCH operations, where omitting a value means "do not change" while setting it
to
null
means "remove."
Maven
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.github.cmdjulian</groupId>
<artifactId>jackson-undefined</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>
Gradle (Kotlin DSL)
settings.gradle.kts
:
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
maven(url = "https://jitpack.io")
}
}
build.gradle.kts
:
dependencies {
// use kotlin module
implementation("com.github.cmdjulian:jackson-undefined-kotlin:1.0.0")
}
Gradle (Groovy DSL)
settings.gradle
:
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
build.gradle
:
dependencies {
implementation 'com.github.cmdjulian:jackson-undefined:1.0.0'
}
Java
void main() {
Property<String> property = new Property.Absent<>();
// Using Boolean Flags
if (property.isAbsent()) {
System.out.println("Property is absent");
} else if (property.isNull()) {
System.out.println("Property is explicitly set to null");
} else {
System.out.println("Property has a value: " + property.value());
}
// Using switch (Java 17+)
switch (property) {
case Property.Absent<?> absent -> System.out.println("Property is absent");
case Property.Null<?> nullValue -> System.out.println("Property is explicitly null");
case Property.Value<?>(var val) -> System.out.println("Property has value: " + val);
}
}
Kotlin
// Using Boolean Flags
val property: Property<String> = Property.Absent<String>()
when {
property.isAbsent() -> println("Property is absent")
property.isNull() -> println("Property is explicitly null")
else -> println("Property has value: ${property.value()}")
}
// Using when
when (property) {
is Property.Absent<*> -> println("Property is absent")
is Property.Null<*> -> println("Property is explicitly null")
is Property.Value<String> -> println("Property has value: ${property.value}")
}
The Kotlin module provides additional extension functions to make working with Property<T>
more idiomatic in Kotlin.
val property: Property<String> = Property.Value("Hello, World!")
// Using the invoke operator
property { value ->
println("Property value: $value")
}
// Using the value property
println("Property value: ${property.value}")
// Using the invoke operator with receiver
property { ->
println("Property value: $this")
}
When serializing a class containing Property<T>
fields, absent values are omitted entirely, null values are written as
null
, and defined values are written as expected.
public record Person(Property<String> name) {
}
ObjectMapper mapper = new ObjectMapper();
void serialize() throws JsonProcessingException {
mapper.findAndRegisterModules();
Person test = new Person(new Property.Absent<>());
String json = mapper.writeValueAsString(test);
System.out.println(json); // Output: {}
}
When deserializing JSON, the module automatically maps missing properties to Property.Absent
, null
values to
Property.Null
, and present values to Property.Value
.
public record Person(Property<String> name) {
}
ObjectMapper mapper = new ObjectMapper();
void deserialize() throws JsonProcessingException {
Person person1 = mapper.readValue("{\"name\":\"John\"}", Person.class);
assert person1.name().value().equals("John");
Person person2 = mapper.readValue("{\"name\":null}", Person.class);
assert person2.name().isNull();
Person person3 = mapper.readValue("{}", Person.class);
assert person3.name().isAbsent();
}
The Property<T>
type is designed to work seamlessly alongside Optional<T>
:
Property.Value<T>
behaves similarly toOptional.of(T)
Property.Null<T>
behaves likeOptional.empty()
Property.Absent<T>
is distinct, indicating the value was never specified and instead of returning anOptional
it will returnnull
to indicate that the value was not specified.
To convert between them:
Optional<String> optional = new Property.Value<>("John").asOptional();
Property<String> propertyFromOptional = optional.<Property<String>>map(Property.Value::new)
.orElseGet(Property.Null::new);
The JacksonPropertyModule
is automatically registered via Java's ServiceLoader
mechanism. This means that if you
have the module on your classpath, Jackson will automatically discover and register it.
Simply call ObjectMapper.findAndRegisterModules()
.
If you prefer to register the module manually, you can do so by adding it to your ObjectMapper
instance:
void main() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JacksonPropertyModule());
}
Consider a JSON payload and a class UserProfile
with multiple attributes:
JSON Payload:
{
"username": "jdoe",
"email": null,
"age": 25,
"address": {
"street": "123 Main St",
"city": null
}
}
Java Class:
public class UserProfile {
public Property<String> username;
public Property<String> email;
public Property<Integer> age;
public Property<Address> address;
public record Address(String street, Property<String> city, Property<String> zip) {
}
}
Deserialization:
void main() throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
String jsonPayload = """
{
"username": "jdoe",
"email": null,
"age": 25,
"address": {
"street": "123 Main St",
"city": null
}
}""";
var userProfile = mapper.readValue(jsonPayload, UserProfile.class);
// Accessing values
System.out.println("Username: " + userProfile.username.value()); // Output: jdoe
System.out.println("Email: " + (userProfile.email.asOptional().orElse("fallback"))); // Output: fallback
System.out.println("Age: " + userProfile.age.value()); // Output: 25
System.out.println("Street: " + userProfile.address.map(UserProfile.Address::street).value()); // Output: 123 Main St
userProfile.address.visit(address ->
address.city.visit(city ->
System.out.println("City: " + city)) // Output: City: null
);
switch (userProfile.address.fold(UserProfile.Address::zip)) {
case Property.Value<String>(var value) -> System.out.println("Zip: " + value);
case Property.Absent<?> _ -> System.out.println("Zip: absent");
case Property.Null<?> _ -> System.out.println("Zip: null");
} // Output: Zip: absent
}
We welcome contributions! If you’d like to contribute:
- Fork the repository
- Create a feature branch
- Commit your changes
- Submit a pull request
This project is licensed under the MIT License.