Modern Java — particularly Java 16 through 21 — introduced several language features that change how you model data: Records for immutable value objects, Optional for nullable return types, and Sealed Classes for closed type hierarchies. Each of these interacts with Jackson in ways that require specific setup. This guide shows you exactly what to configure so that Jackson handles all three correctly.
Runnable code: every example below is in the jackson3-by-example repository, compiled and executed against Jackson 3.2.1 on JDK 21 — C01 records, C02 Optional, C03 sealed types and C04 sealed auto-discovery without @JsonSubTypes, with captured output in docs/part2-modern-java.md.
Jackson with Java Records
Java Records are ideal DTOs: they are immutable, concise, and carry their own equals(), hashCode(), and toString() implementations. Jackson 3 supports Records natively with zero additional modules — the canonical constructor is used automatically for deserialisation and record accessor methods replace getters for serialisation.
Define a Record:
// A concise, immutable data transfer object
public record ProductRecord(
Long productId,
String productName,
double unitPrice
) {}
Serialise and deserialise it:
JsonMapper mapper = JsonMapper.builder().build();
// Serialise: Record -> JSON
ProductRecord product = new ProductRecord(101L, "Wireless Keyboard", 49.99);
String jsonOutput = mapper.writeValueAsString(product);
System.out.println(jsonOutput);
// Output: {"productId":101,"productName":"Wireless Keyboard","unitPrice":49.99}
// Deserialise: JSON -> Record
String json = "{"productId":101,"productName":"Wireless Keyboard","unitPrice":49.99}";
ProductRecord restored = mapper.readValue(json, ProductRecord.class);
System.out.println(restored.productName()); // Output: Wireless Keyboard
How it works: Jackson detects that ProductRecord is a record, uses the canonical constructor for deserialisation, and uses the accessor methods (productId(), etc.) for serialisation. No annotations needed.
In Jackson 3, record support is built into the core — no extra dependency, module registration or compiler flag is needed. Add tools.jackson.core:jackson-databind:3.2.1 and records work out of the box.
<!-- This is the whole dependency list. jackson-module-parameter-names is a -->
<!-- Jackson 2 artifact and is NOT needed here - it has no 3.x release. -->
<!-- Records work via the RecordComponent reflection API, so neither a module -->
<!-- nor the -parameters compiler flag is required. -->
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>3.2.1</version>
</dependency>
Jackson with Optional<T>
Optional is Java’s way of expressing “this value might not be present” without using null. In Jackson 2 this required the separate jackson-datatype-jdk8 artifact and an explicit registerModule(new Jdk8Module()) call. In Jackson 3 it is built into jackson-databind and needs neither — in fact the Jdk8Module class does not exist under tools.jackson, so carrying the registration across from a Jackson 2 codebase is a compile error rather than a harmless leftover:
<!-- NOT NEEDED in Jackson 3. jackson-datatype-jdk8 is a Jackson 2 artifact and -->
<!-- has no 3.x release: Optional support is built into jackson-databind 3.x. -->
<!-- The Jdk8Module class does not exist under tools.jackson at all, so a -->
<!-- registerModule(new Jdk8Module()) call is a compile error, not a no-op. -->
<!-- Nothing to add here - jackson-databind alone is enough. -->
No registration is needed — this is the entire setup:
// Jackson 3: Optional support is in core. No Jdk8Module, no extra dependency.
JsonMapper mapper = JsonMapper.builder().build();
Now define a class that uses Optional:
public class CustomerProfile {
private String customerName;
private Optional<String> middleName; // May not be present
// Constructors, getters, setters
}
Serialise and deserialise:
// Serialise: present value
CustomerProfile profileWithMiddle = new CustomerProfile("Alice", Optional.of("Marie"));
System.out.println(mapper.writeValueAsString(profileWithMiddle));
// Output: {"customerName":"Alice","middleName":"Marie"}
// Serialise: empty Optional
CustomerProfile profileNoMiddle = new CustomerProfile("Bob", Optional.empty());
System.out.println(mapper.writeValueAsString(profileNoMiddle));
// Output: {"customerName":"Bob","middleName":null}
// Deserialise back
String json = "{"customerName":"Alice","middleName":"Marie"}";
CustomerProfile restored = mapper.readValue(json, CustomerProfile.class);
System.out.println(restored.getMiddleName().isPresent()); // Output: true
Jackson with Sealed Classes (Java 17+)
Sealed classes define a closed set of permitted subtypes, making them a natural fit for polymorphic JSON — the set of possible concrete types is fixed at compile time, which maps directly onto Jackson’s @JsonTypeInfo + @JsonSubTypes mechanism.
Sealed classes define a closed set of permitted subtypes. This is exactly the use case that Jackson’s polymorphic deserialisation was designed for. Combine them with @JsonTypeInfo and @JsonSubTypes:
// Sealed hierarchy: only Circle and Rectangle are permitted shapes
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "shapeType"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = Circle.class, name = "circle"),
@JsonSubTypes.Type(value = Rectangle.class, name = "rectangle")
})
public sealed interface Shape permits Circle, Rectangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
Deserialise a mixed list of shapes from JSON:
String json = "["
+ "{"shapeType":"circle","radius":5.0},"
+ "{"shapeType":"rectangle","width":10.0,"height":4.0}"
+ "]";
List<Shape> shapes = mapper.readValue(json, new TypeReference<List<Shape>>() {});
for (Shape shape : shapes) {
if (shape instanceof Circle c) {
System.out.println("Circle with radius: " + c.radius());
} else if (shape instanceof Rectangle r) {
System.out.println("Rectangle " + r.width() + " x " + r.height());
}
}
// Output:
// Circle with radius: 5.0
// Rectangle 10.0 x 4.0
How the Code Works
- Records: Jackson detects the canonical constructor via reflection and uses it during deserialisation. Accessor methods (e.g.,
productId()) replace getters for serialisation. - Optional: In Jackson 3 the core deserialiser wraps the JSON value in
Optional.of()when present and returnsOptional.empty()for a null or missing value — so the field is nevernull. An emptyOptionalserialises asnullunless you apply@JsonInclude(NON_ABSENT), which omits the property entirely. - Sealed Classes + @JsonTypeInfo: Jackson reads the
shapeTypediscriminator property first, looks up the matching subtype in the@JsonSubTypesregistry, then instantiates that concrete class and populates its fields.
See Also
- Part 1: Jackson ObjectMapper Complete Guide
- Part 3: Jackson Annotations Cheat Sheet
- Part 5: Polymorphic Deserialisation with @JsonTypeInfo
AI Prompts for Jackson with Modern Java Types
Record Round-Trip
I have this Java record class: [paste your record here]. I am using Jackson 3.2.1 with jackson-databind only — no extra modules. Show me a complete round-trip example: serialise an instance to a JSON string, print the output, then deserialise that string back to the record type. Explain which constructor Jackson uses during deserialisation and which methods it calls during serialisation. Confirm no @JsonCreator or -parameters compiler flag is needed.
What it does: Produces a working serialise-then-deserialise example tailored to your specific record, showing the exact JsonMapper.builder().build() setup and explaining how Jackson 3 uses the RecordComponent API to locate the canonical constructor and accessor methods automatically.
When to use it: When adding a new record DTO to a Jackson 3 project and wanting verified code before writing tests. Also useful when upgrading from Jackson 2 to confirm that jackson-module-parameter-names and the -parameters flag are no longer needed.
Optional Field Mapping
I have this class with Optional<T> fields: [paste your class here]. I am upgrading from Jackson 2 to Jackson 3.2.1. Show me how Optional fields behave in Jackson 3 compared to Jackson 2: which module registration is no longer needed, what the serialised JSON looks like when the Optional is present vs empty, and how to control whether an empty Optional serialises as null or is omitted entirely.
What it does: Produces a side-by-side before/after showing the Jackson 2 approach (explicit Jdk8Module registration) and the Jackson 3 approach (no registration needed), with sample JSON output for both Optional.of("value") and Optional.empty() cases, and shows how @JsonInclude(NON_ABSENT) controls omission of empty optionals.
When to use it: When migrating a class that has Optional<String>, Optional<LocalDate>, or similar fields from Jackson 2 to Jackson 3, or when the serialised JSON is producing unexpected {"value":{"present":true}} wrapper output that signals the module was not registered.
Sealed Interface Polymorphism
I have this sealed interface and its permitted record implementations: [paste your hierarchy here]. Add the minimum Jackson 3 annotations to make the hierarchy fully serialisable and deserialisable. Use a “type” discriminator field in the JSON. Show a sample JSON payload for each permitted subtype, a round-trip deserialisation test using JsonMapper, and explain what happens at runtime when an unknown type discriminator value is encountered.
What it does: Applies @JsonTypeInfo(use=Id.NAME, property="type") to the sealed interface and @JsonTypeName to each permitted record, produces sample JSON for every subtype, and includes a JsonMapper round-trip test that verifies each variant deserialises to the correct concrete type — including the error thrown for unrecognised discriminator values.
When to use it: When modelling a closed domain hierarchy — such as payment methods, event types, or notification channels — as a sealed interface in Java 17+ and needing Jackson to dispatch deserialisation to the correct record type based on a discriminator field in the JSON payload.
Conclusion
In Jackson 3 all three of these land in the core artifact, which is the single biggest simplification of the upgrade: records, Optional and java.time need no modules, no annotations and no -parameters flag. Sealed types pair naturally with @JsonTypeInfo, and Jackson 3 goes one step further than Jackson 2 by introspecting the permits clause — so @JsonSubTypes can be dropped entirely as long as each permitted type carries @JsonTypeName, removing a parallel registry that used to drift out of sync every time someone added a subtype. Together these let you write lean, expressive data models without sacrificing Jackson’s full power.
No Comments yet!