Deserializing Polymorphic Lists with Kotlin, Jackson, and Spring Boot

alexmacarthur

Alex MacArthur

Posted on November 25, 2023

Deserializing Polymorphic Lists with Kotlin, Jackson, and Spring Boot

One of my favorite features my of TypeScript is discriminated unions, in which a common literal type is used to determine the narrowest possible type of an object. Think of two types of messages – Email and SMS, with types defined as such:

interface Email {
    kind: 'email'; // <- literal type
    subject: string;
    from: string;
    to: string;
    body: string;
}

interface SMS {
    kind: 'sms'; // <- literal type
    sender: string;
    recipient: string;
    message: string;
}

// A union type, discriminated by "kind":
type Message = Email | SMS;

// TypeScript error:
// Property 'content' is missing in type 
// '{ kind: "sms"; sender: string; recipient: string; }' 
// but required in type 'SMS'.
const myMessage: Message = {
    kind: 'sms',
    sender: '+11234567890',
    recipient: '+10987654321',
};

Enter fullscreen mode Exit fullscreen mode

Since myMessage has a literal of kind: 'sms', TypeScript knows it's an SMS, prompting it for the missing content property. No further configuration necessary.

Submitting a Polymorphic List to a REST Endpoint

This is a useful feature for a number of cases. One of them is submitting a list of data composed of multiple types (a polymorphic list) to a REST endpoint. Here's an example with Fastify, using the same Message type defined above.

example fastify route showing typescript error

As you can see, when we attempt to pull content off of what TypeScript knows is an Email, it gets mad. For good reason!

typescript error message

Thanks to that discriminated union, it knows that if it's an "email" it can't have a content property, preventing us from writing bugs we'd otherwise only find at runtime.

Validating the Payload at Runtime

As a secondary benefit, because we're using Fastify, it's easy to validate that incoming request too, albeit much more verbosely than simply defining some types. Here's how that'd look:

const emailSchema = {
    type: "object",
    required: ["kind", "subject", "from", "to", "body"],
    properties: {
        kind: { type: "string", enum: ["email"] },
        subject: { type: "string" },
        from: { type: "string" },
        to: { type: "string" },
        body: { type: "string" },
    },
};

const smsSchema = {
    type: "object",
    required: ["kind", "sender", "recipient", "content"],
    properties: {
        kind: { type: "string", enum: ["sms"] },
        sender: { type: "string" },
        recipient: { type: "string" },
        content: { type: "string" },
    },
};

server.addSchema({
    $id: "message",
    oneOf: [emailSchema, smsSchema],
});
Enter fullscreen mode Exit fullscreen mode

We can then tell Fastify to require each item in the provided array to be a "message."

server.post<{
  Body: Array<Message>;
}>(
    "/submit",
    {
        schema: {
            body: {
                type: "array",
                items: { $ref: "message#" },
            },
        },
    },
    (request, reply) => {
        // handle it
    }
);
Enter fullscreen mode Exit fullscreen mode

And we'd get a clear error message if we left any required property off:

fastify validation error message

In all, we've got some really nice guardrails to safely reason about everything once we've made it past the request boundary.

Things Look Different in Spring Boot, Jackson, and Kotlin

I'm not that well-versed in the JVM world. I started working in it only a couple of years ago, and it hasn't even been consistent. Still, I was hopeful this sort of convenience would exist when I wanted to submit a similar sort of list in a Spring Boot application, built with Kotlin and using the Jackson serialization library. Think of a simple endpoint accepting a list of Message objects:

@PostMapping("/submit")
fun submitMessages(
    @RequestBody
    messages: List<Message>,
): String {
    // do stuff
}
Enter fullscreen mode Exit fullscreen mode

As it turns out, it doesn't have the same ergonomics as TypeScript's discriminated unions, but it's still possible to get it done. To pull it off, we're gonna use a sealed class, a neat Kotlin feature useful for creating a restricted class hierarchy. In an ideal world, this is how that class would look:

sealed class Message {
    data class Email(
        val subject: String,
        val from: String,
        val to: String,
        val body: String,
    ) : Message()

    data class SMS(
        val sender: String,
        val recipient: String,
        val content: String,
    ) : Message()
}
Enter fullscreen mode Exit fullscreen mode

It's pretty tidy, and after deserialization, it'd let us use Kotlin's when operator to handle each subtype as needed, despite both of them being a Message:

messages.map {
    when (it) {
        is Message.Email -> print("email!")
        is Message.SMS -> print("sms!")
    }
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, in order for Jackson to deserialize the request payload correctly, that sealed class will need to get a little more complex.

Annotating the Model

First up, we'll add two annotations to our Message class that Jackson will use to determine how to handle the types within our submitted list.

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "kind",
)
@JsonSubTypes(
    JsonSubTypes.Type(value = Message.Email::class, name = "email"),
    JsonSubTypes.Type(value = Message.SMS::class, name = "sms"),
)
Enter fullscreen mode Exit fullscreen mode

A brief breakdown:

  • The @JsonTypeInfo annotation tells Jackson that a "kind" property will exist on each item in the payload.
  • The @JsonSubTypes annotation tells Jackson which object within our sealed class to instantiate, based on whatever was passed as the value of "kind."

To put it another way: when an item has a "kind" of "email," Jackson will instantiate a new Message.Email. When it's "sms," it'll create a Message.SMS.

Modifying Our Sealed Class Subclasses

Since we're now relying on the "kind" property to tell Jackson what to instantiate, we need to refactor the members our sealed class a bit too. Here's how it'll look:

data class EmailData(
    val subject: String,
    val from: String,
    val to: String,
    val body: String,
)

data class SMSData(
    val sender: String,
    val recipient: String,
    val content: String,
)

// Aformentioned annotations go here.
sealed class Message {
    data class Email(
        @JsonProperty("message")
        val message: EmailData,
    ) : Message()

    data class SMS(
        @JsonProperty("message")
        val message: SMSData,
    ) : Message()
}
Enter fullscreen mode Exit fullscreen mode

As you can see, neither shape has its information declared as top-level properties anymore. Since we needed to make room for a kind of message, it's been relegated to a message property, with the details being extracted to EmailData and SMSData classes.

Not as clean as I'd like it to be, but it'll do the job.

Validating Everything

We can test this out by modifying our endpoint to spit back the messages we provide. Obviously, in a production application, you'd be doing something more interesting.

@PostMapping("/submit")
fun submitMessages(
    @RequestBody
    messages: List<Message>,
): String = messages.map {
    when (it) {
        is Message.Email -> "email"
        is Message.SMS -> "sms"
    }
}.joinToString { it }
Enter fullscreen mode Exit fullscreen mode

We'll hit it with the following payload:

[
    {
        "kind": "sms",
        "message": {
            "sender": "+11234567890", 
            "recipient": "+10987654321",
            "content": "hey"
        }
    },
    {
        "kind": "email",
        "message": {
            "subject": "a subject", 
            "from": "me@example.com", 
            "to": "you@example.com", 
            "body": "hello"
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

Spring is able to deserialize the payload into the objects we expect, and we get back what we intended:

the expected response from a POST request

And just by relying on plain, ol' Kotlin types, we get some useful feedback when submitting invalid data too. Here's what we get just by leaving off "content": "hey" in the above payload:

a 400 response caused by a missing property

Based on what we set as the message "kind," Spring knows exactly what to enforce on the incoming request, even without using a more mature validation library like Javax or Jakarta (which are recommended in more fleshed out applications, by the way).

So, any client will get the feedback they need when attempting to submit data, and our application will have full type knowledge when handling that request to completion.

Shout-Out to a Potentially Better Design

I've heard feedback that designing a request payload like this might be more of an interesting, "smelly" design than a good one. A better approach might be to accept a MessagePayload model, which would hold both SMS and Email messages. Something like this (beware... untested code):

sealed class Message {
    data class Email(
        val subject: String,
        val from: String,
        val to: String,
        val body: String,
    ) : Message()

    data class SMS(
        val sender: String,
        val recipient: String,
        val content: String
    ) : Message()
}

data class MessagePayload(
    val smsMessages: List<Message.SMS>,
    val emailMessages: List<Message.Email>
)
Enter fullscreen mode Exit fullscreen mode

Admittedly, it does read easier, and would require less dependence on Jackson's annotations to inform Kotlin how to build out the items within the payload. I think there's a lot of value in that. In general, incline yourself toward simpler, more readable code.

But... there also doesn't seem to be a demonstrable benefit to this over a polymorphic approach. In the end, the way we're handling a polymorphic list gives us full type safety + request validation, and it might also expose a more useful interface to whatever clients will be using the endpoint.

Like any other engineering problem, it can probably be reduced to "it depends." Even so, I'm curious to hear your thoughts on how to simplify this, and whether there are legitimate reasons to avoid such a design in favor of another.

💖 💪 🙅 🚩
alexmacarthur
Alex MacArthur

Posted on November 25, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related