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:
- Handle different types of objects as input with the maximum possible typing without resorting to
String,Object,anyor other language-specific tricks. - Handle legacy or unknown data without it being typed (incremental typing).
- 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:
- create
- update
- 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
sealedkeyword ensures AbstractEvent knows all its direct subclasses at compile time, maintaining compatibility with clients. - •
@JsonTypeInfois deserializing JSON polymorphically. Here we use thetypeproperty to match types, and if it does not match with one of@JsonSubTypes,UnknownEventis set as default. - •
@JsonSubTypeshow 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;
}
}
- •
finalmakes children unable to be extended. - • The known events are a POJO with lombok annotations to generate getters and builder, nothing else.
- •
UpdateEventandDeletionEventare the same asCreationEvent, 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;
}
}
- •
@DelegatemakesUnknownEventappear to have all the attributes of its siblings. This is transparent to the object's callers. - • With
@JsonAnyGetterand@JsonAnySetterwe 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
switchon the data type (A.K.A. aninstanceof) 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
switchis exhaustive because the parent issealed(note it has nodefaultbranch).