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',
};
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.
As you can see, when we attempt to pull content
off of what TypeScript knows is an Email
, it gets mad. For good reason!
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],
});
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
}
);
And we'd get a clear error message if we left any required property off:
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
}
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()
}
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!")
}
}
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"),
)
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()
}
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 }
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"
}
}
]
Spring is able to deserialize the payload into the objects we expect, and we get back what we intended:
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:
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>
)
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.
Top comments (0)