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
nullmeans "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 anOptionalit will returnnullto 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.
