Blog
NULL vs UNDEFINED when dealing with JSON in Java
NULL vs UNDEFINED: Why Should I Care?
In most cases, the difference between null and undefined is not important when dealing with JSON serialization. The common advice is to omit the value (although many Java developers, even when using OpenAPI, incorrectly serialize null when their OpenAPI specification says the value is optional). Although this upsets me, it's less of a problem when consuming the JSON in JavaScript: a truthiness check treats both null and undefined as falsy.
There is one API-design case where null and undefined make a real difference: when you want to support patch or partial-update functionality (e.g., JSON Merge Patch).
In my little side project (RSD), I support patch operations in what I think is a very elegant way. If you want your record to have a patch version alongside the full version, all you need to do is add patchable before your record definition.
So to go from a simple record:
record Person {
firstname?: string;
lastname: string;
}
to a patchable one looks like this:
patchable record Person {
firstname?: string;
lastname: string;
}
The code generators will then create two versions of your record. The code below is not exactly the Java code you'll get; I've omitted some details to keep this blog post simple:
interface Person {
public interface Data {
public Optional<String> firstname();
public String lastname();
}
public interface Patch {
public Nillable<String> firstname();
public Optional<String> lastname();
}
}
If you pay close attention to the Patch#firstname property, you'll see that the Optional<String> from Data#firstname becomes Nillable<String>.
This allows you to express "Update the lastname to 'Doe'" with:
{
"lastname": "Doe"
}
or "Clear the firstname and update the lastname to 'Doe'":
{
"firstname": null,
"lastname": "Doe"
}
What bugs me is that Java has no built-in solution or established best practice for handling this. For jackson-databind, there is, for example, jackson-databind-nullable. As far as I can tell, Jakarta JSON-B has no public extension.
The Nillable Type in RSD
Because there is no general best practice or library for this in Java, RSD rolls its own version. Since the generated code is self-contained from an API point of view, our code generator produces the following interface:
interface Nillable<T> {
/**
* Transforms the contained value using the provided function if it is not undefined,
* or returns the default value if it is undefined. If the value is explicitly
* null, the function will be applied to null, allowing for handling of the null
* case as well.
*
* @param <R> the type of the result produced by the function
* @param function the function to apply to the contained value if it is
* defined
* @param defaultValue the value to return if this Nillable is undefined
* @return the result of applying the function to the contained value, or the
* default value if undefined
*/
public <R> R apply(Function<T, R> function, R defaultValue);
/**
* Performs the given action on the contained value if it is not undefined. If the
* value is explicitly null, the action will be performed with null, allowing
* for handling of the null case as well. If the value is undefined, the action
* will not be performed at all.
*
* @param block the action to perform on the contained value
*/
public void accept(Consumer<T> block);
/**
* Transforms the contained value using the provided mapping function if this
* Nillable is not undefined, returning a new Nillable containing the result. If this Nillable is
* undefined, the result will also be undefined. If this Nillable is explicitly
* null, the result will also be null. This allows for chaining transformations
* on Nillable values while preserving the distinction between undefined and
* null states.
*
* @param <R> the type of the result produced by the mapping function
* @param mapper the function to apply to the contained value if this Nillable is not undefined
* @return a new Nillable containing the result of applying the mapping
* function, or undefined/null if the original Nillable was
* undefined/null
*/
public <R> Nillable<R> map(Function<T, R> mapper);
/**
* Checks if this Nillable is in the undefined state, meaning that no value was
* provided. This is different from being explicitly null, which indicates
* that a value was intentionally set to null.
*
* @return true if this Nillable is undefined, false otherwise
*/
public boolean isUndefined();
/**
* Checks if this Nillable is in the null state, meaning that a value was
* provided but it is explicitly null. This is different from being undefined,
* which indicates that no value was provided at all.
*
* @return true if this Nillable is null, false otherwise
*/
public boolean isNull();
/**
* Returns the contained value if it is present, or the provided default value if it is undefined
* or null. This allows you to retrieve a value from a Nillable while providing a fallback
* when it is undefined or explicitly null.
*
* @param defaultValue the value to return if this Nillable is undefined or null
* @return the contained value if defined, or the default value if undefined or null
*/
public T orElse(T defaultValue);
/**
* Returns the contained value if it is present, or the result of the provided supplier function if
* it is undefined or null. This allows you to provide a default value lazily, computing it only when needed.
* This can be useful for expensive computations or when the default value depends on other factors.
*
* @param defaultValueSupplier the function to compute the default value if this Nillable is undefined or null
* @return the contained value if defined, or the result of the supplier function if undefined or null
*/
public T orElseGet(Supplier<T> defaultValueSupplier);
/**
* Converts this Nillable to an Optional, treating both undefined and null states as empty.
* This allows interoperability with APIs that use Optional to represent optional values. The original
* Nillable still retains the distinction between undefined and null.
*
* @return an Optional containing the value if defined, or empty if undefined or null
*/
public Optional<T> toOptional();
}
Let's see the API in action:
Nillable<String> value = ...
// Do a transformation
var r = value.apply(v -> {
if(v == null) {
return Integer.MAX_VALUE;
}
return Integer.parseInt(v);
}, 0);
// Execute an action
value.accept( v -> {
if(v != null) {
// reset data
return;
}
// update data
} );
// Do a chain
value.map(LocalDate::parse).accept( date -> {
if(date == null) {
// reset the date
return;
}
// update the date
});
// Extract the value but never get null
var d = value.orElse("No Value");
// Transform to an Optional, losing the ability to
// differentiate between null/undefined
var o = value.toOptional()