Franco Battaglia

Polymorphic typing for JSON inputs in Java

If you're a web developer, you might have come across having to design a REST endpoint that supports different types of request payloads, each with its corresponding fields.

The requirement is as follows:

  1. Handle different types of objects as input with the maximum possible typing without resorting to String, Object, any or other language-specific tricks.
  2. Handle legacy or unknown data without it being typed (incremental typing).
  3. Add new cases forcing the client to deal with them at compile time.

This post is a solution using Java 22, Jackson and Lombok. Why? This approach is employed in my current project due to specific requirements. I think this solution is adaptable to other languages.

Note: if the language you use supports union types, the correct path is probably to refer to its documentation.

"events" example

Let POST /events be an endpoint which receives an event payload. The events can be:

  1. create
  2. update
  3. delete

but since we're dealing with legacy code, we know there are other types of events; and we need to accommodate future additions.

Furthermore, the unknown events may have fields with the same name as the known types.

Let's see example JSONs that we can receive:

Create
{
  "type": "creation",
  "created_at": "2024-09-09T17:00:00",
  "table_name": "users"
}
Update
{
  "type": "update",
  "created_at": "2024-09-09T17:00:00",
  "new_name": "orders"
}
Delete
{
  "type": "deletion",
  "created_at": "2024-09-09T17:00:00",
  "deletion_mode": "logical",
  "table_to_delete": "items"
}
An unknown event to our API (it might have a type)
{
  "created_at": "2024-09-09T17:30:00",
  "table_name": "users",
  "items_quantity": 1,
  "deletion_mode": "physical",
  "this_is_not_in_any_class_so_what_can_we_do_about_it": "get it anyways :)",
  "are_you_reading_this": true
}

From the above cases, let's sketch a class hierarchy with AbstractEvent as parent and CreationEvent, UpdateEvent, and DeletionEvent as children:

AbstractEvent
@Data
@NoArgsConstructor
@SuperBuilder

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true, defaultImpl = UnknownEvent.class)
@JsonSubTypes({
        @JsonSubTypes.Type(value = CreationEvent.class, name = "creation"),
        @JsonSubTypes.Type(value = UpdateEvent.class, name = "update"),
        @JsonSubTypes.Type(value = DeletionEvent.class, name = "deletion")
})
public sealed abstract class AbstractEvent permits CreationEvent, UpdateEvent, DeletionEvent, UnknownEvent {
    LocalDateTime createdAt;
    String type;

    abstract boolean isKnownEvent(); // this method is not needed and must be considered a convenience for API consumers
}
  • • The sealed keyword ensures AbstractEvent knows all its direct subclasses at compile time, maintaining compatibility with clients.
  • @JsonTypeInfo is deserializing JSON polymorphically. Here we use the type property to match types, and if it does not match with one of @JsonSubTypes, UnknownEvent is set as default.
  • @JsonSubTypes how to match said values.
  • isKnownEvent() is not part of the solution.
CreationEvent
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@SuperBuilder
@ToString(callSuper=true)

@Jacksonized
public final class CreationEvent extends AbstractEvent {
    private String tableName;

    @Override
    boolean isKnownEvent() {
        return true;
    }
}
  • final makes children unable to be extended.
  • • The known events are a POJO with lombok annotations to generate getters and builder, nothing else.
  • UpdateEvent and DeletionEvent are the same as CreationEvent, except for their attributes.

How can we handle unknown events (inputs)?

Here's the key point: in Java there is no multiple inheritance, so we have to help ourselves with delegates to model a more or less typed encompasses-all type.

This type UnknownEvent should have the attributes of all its siblings, plus any unknown fields:

UnknownEvent
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper=true)

// null fields should not be output, as this class will contain all the possible fields of its siblings
@JsonInclude(JsonInclude.Include.NON_NULL)
public final class UnknownEvent extends AbstractEvent {
    // 'delegate' as in standard OOP delegation, this makes Java pass all the fields of delegate to this class fields while maintaining original getter/setter logic
    @Delegate
    // we should not serialize delegates
    @JsonIgnore
    // initialize delegates as to avoid NPE
    private CreationEvent creationEvent = new CreationEvent();
    @Delegate
    @JsonIgnore
    private UpdateEvent updateEvent = new UpdateEvent();
    @Delegate
    @JsonIgnore
    private DeletionEvent deletionEvent = new DeletionEvent();

    // use a Map<String, Object> to store unknown fields. these should be serialized in a plain fashion (unknownFields must not be a JSON output field)
    private Map<String, Object> unknownFields = new HashMap<>();

    @JsonAnyGetter
    public Map<String, Object> getUnknownFields() {
        return unknownFields;
    }

    @JsonAnySetter
    public void setUnknownFields(String name, Object value) {
        unknownFields.put(name, value);
    }

    @Override
    boolean isKnownEvent() {
        return false;
    }
}
  • @Delegate makes UnknownEvent appear to have all the attributes of its siblings. This is transparent to the object's callers.
  • • With @JsonAnyGetter and @JsonAnySetter we take unmapped fields to the Java object representation in memory.
  • • If we introduce a new event type, in addition to coding it in the parent class accordingly, we have to include it as a delegate here. Thankfully, forgetting this step won't break anything.

Show me the code!

// instantiate helper objects
FileUtils fileUtils = new FileUtils();
Mapper mapperGenerator = new Mapper();
ObjectMapper mapper = mapperGenerator.getMapper();

// read from JSON files to Java objects (in a real-world scenario, this could be the input of an HTTP request in a controller)
AbstractEvent creationEvent = mapper.readValue(fileUtils.getFileFromResourceAsStream("examples/creation_event_1.json"), AbstractEvent.class);
AbstractEvent updateEvent = mapper.readValue(fileUtils.getFileFromResourceAsStream("examples/update_event_1.json"), AbstractEvent.class);
AbstractEvent deletionEvent = mapper.readValue(fileUtils.getFileFromResourceAsStream("examples/deletion_event_1.json"), AbstractEvent.class);
AbstractEvent notInCodeTypeEvent = mapper.readValue(fileUtils.getFileFromResourceAsStream("examples/random_event_1.json"), AbstractEvent.class);

// create a list of the read events, with the list type being the closest parent to all events
List<AbstractEvent> events = List.of(creationEvent, updateEvent, deletionEvent, notInCodeTypeEvent);

// process each event according to its type
events.forEach(event -> {
    switch (event) {
        // for known events, further usage and serialization are straightforward
        case CreationEvent c -> println("Received a creation event:" + c);
        case UpdateEvent u -> println("Received an update event: " + u);
        case DeletionEvent d -> println("Received a deletion event: " + d);
        // for unhandled or unknown events, we set the following goals:
        // 1. the unknown event must contain all the typed values of all its 'sibling' classes
        // 2. the unknown event must handle the case in which there are additional unmapped (untyped) fields
        case UnknownEvent un -> {
            println("Received an unknown event: " + un);
            // accomplishing (1)
            println("I can access properties of the unknown event in a type safe way, knowing those might be null. Deletion mode (should not be null): " + un.getDeletionMode() + ", table name (should not be null): " + un.getTableName() + ",  new name (should be null): " + un.getNewName());
            try {
                // accomplishing (2)
                String unkownEventAsJson = mapper.writeValueAsString(un);
                println("An unknown event is safe to serialize as JSON, and it will behave as the union of all other types plus untyped fields: " + unkownEventAsJson);
            } catch (JsonProcessingException ex) {
                throw new RuntimeException(ex);
            }
        }
    }
});

Output:

Received a creation event:CreationEvent(super=AbstractEvent(createdAt=2024-09-09T17:00, type=creation), tableName=users)
Received an update event: UpdateEvent(super=AbstractEvent(createdAt=2024-09-09T17:00, type=update), newName=orders)
Received a deletion event: DeletionEvent(super=AbstractEvent(createdAt=2024-09-09T17:00, type=deletion), deletionMode=logical, tableToDelete=items)
Received an unknown event: UnknownEvent(super=AbstractEvent(createdAt=2024-09-09T17:30, type=null), creationEvent=CreationEvent(super=AbstractEvent(createdAt=2024-09-09T17:30, type=null), tableName=users), updateEvent=UpdateEvent(super=AbstractEvent(createdAt=null, type=null), newName=null), deletionEvent=DeletionEvent(super=AbstractEvent(createdAt=null, type=null), deletionMode=physical, tableToDelete=null), unknownFields={are_you_reading_this=true, items_quantity=1, this_is_not_in_any_class_so_what_can_we_do_about_it=get it anyways :)})
I can access properties of the unknown event in a type safe way, knowing those might be null. Deletion mode (should not be null): physical, table name (should not be null): users,  new name (should be null): null
An unknown event is safe to serialize as JSON, and it will behave as the union of all other types plus untyped fields: {"created_at":"2024-09-09T17:30:00","table_name":"users","deletion_mode":"physical","are_you_reading_this":true,"items_quantity":1,"this_is_not_in_any_class_so_what_can_we_do_about_it":"get it anyways :)"}
  • • Jackson is instantiating the subclasses correctly thanks to the annotations.
  • • With a switch on the data type (A.K.A. an instanceof) we can handle each event type as we please.
  • • The unknown event is typed for the known properties and preserves the unknown ones when it is reserialized.
  • • Adding or removing events as input does not break the API (those will be UnknownEvent).
  • • Typing new events in the code forces the client to handle the case at compile time. The switch is exhaustive because the parent is sealed (note it has no default branch).