Jackson with Java Records, Optionals, and Sealed Classes (Java 21+)

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.

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:

ObjectMapper mapper = new ObjectMapper();

// 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 or module registration is needed. Simply add jackson-databind:3.1.2 and records work out of the box.

<!-- jackson-databind 2.12+ includes record support; no extra module required -->
<!-- For older builds, explicitly add: -->
<dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-parameter-names</artifactId>
    <version>3.1.2</version>
</dependency>

Jackson with Optional<T>

Optional is Java’s way of expressing “this value might not be present” without using null. Out of the box, Jackson does not understand Optional — it treats it as a plain object. To handle it correctly, add the JDK8 module:

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jdk8</artifactId>
    <version>3.1.2</version>
</dependency>

Register it on your mapper:

ObjectMapper mapper = new ObjectMapper()
    .registerModule(new Jdk8Module());

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

  1. Records: Jackson detects the canonical constructor via reflection and uses it during deserialisation. Accessor methods (e.g., productId()) replace getters for serialisation.
  2. Optional: The JDK8 module registers a custom deserialiser that wraps the JSON value in Optional.of() if present, or returns Optional.empty() for null/missing values.
  3. Sealed Classes + @JsonTypeInfo: Jackson reads the shapeType discriminator property first, looks up the matching subtype in the @JsonSubTypes registry, then instantiates that concrete class and populates its fields.

See Also

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.1.2 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.1.2. 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

Modern Java features integrate seamlessly with Jackson once you know which module to register and which annotation to reach for. Records work out of the box from Jackson 2.12 onwards. Optionals require the jackson-datatype-jdk8 module. Sealed classes pair naturally with @JsonTypeInfo and @JsonSubTypes to produce safe, exhaustive polymorphic deserialisation. Together these patterns let you write lean, expressive data models without sacrificing Jackson’s full power.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.