In TypeScript, we have discriminated unions. In a few words, it allows the typing analyzer to narrow down to a subtype depending on a property of this item.
I need to parse a JSON response from Notion API, in which there is the schema definition of my database.
[
{
"id": "%3FwQU",
"name": "Groupe",
"type": "select",
"select": {
"options": [
{
"id": "ve;X",
"name": "Gorets",
"color": "green"
},
]
}
},
{
"id": "j%3BQq",
"name": "Lieu",
"type": "rich_text",
"rich_text": {}
},
]
in our case, type
is the discriminant.
How to create a structure that allows me to parse the raw json into something typesafe ?
This is what I have so far:
enum class PropertyType(val type: String) {
Select(type = "select"),
Date(type = "date"),
RichText(type = "rich_text")
}
interface BaseProperty {
val id: String
val name: String
val type: PropertyType
}
val properties: List<Property>
// There must be an easier way ?
sealed class Property: BaseProperty {
data class Select(override val id: String, override val name: String, override val type: PropertyType): Property()
data class Date(override val id: String, override val name: String, override val type: PropertyType): Property()
}
// I'm using kotlinx.serialization.json.JsonTransformingSerializer to transform the value
object TransformProperties: JsonTransformingSerializer<Map<String, JsonObject>>(MapSerializer(String.serializer(), JsonObject.serializer())) {
override fun transformDeserialize(element: JsonElement): JsonElement {
element.jsonObject.values.forEach {
val obj = it.jsonObject;
// Same, is there an easier way ?
val id = obj["id"].toString()
val name = obj["name"].toString()
val type = obj["type"].toString()
when (type) {
PropertyType.Select.name -> Property.Select(id, name, PropertyType.valueOf(type))
PropertyType.Date.name -> Property.Date(id, name, PropertyType.valueOf(type))
}
}
return super.transformDeserialize(element)
}
}
I'm not sure whether this is the correct way to achieve this. What is the best practice on this pattern?
Update:
Following the recommandations from @simon-jacobs , I updated my classes as follows:
@Serializable
sealed class BaseProperty {
abstract val id: String
abstract val name: String
abstract val type: String
}
@Serializable
data class SelectOption(val id: String, val name: String, val color: String)
@Serializable
@SerialName("select")
data class PropertySelect(
override val id: String,
override val name: String,
val options: List<SelectOption> = listOf(),
override val type: String = PropertyType.Select.name
): BaseProperty()
@Serializable
@SerialName("date")
data class PropertyDate(
override val id: String,
override val name: String,
override val type: String = PropertyType.Date.name
): BaseProperty()
@Serializable
@SerialName("rich_text")
data class PropertyRichText(
override val id: String,
override val name: String,
override val type: String = PropertyType.RichText.name
): BaseProperty()
However, there is an issue when serializing objects of type Kotlin is unaware of, for example the error Polymorphic serializer was not found for class discriminator 'last_edited_time'
seems to crash because no serializer is bound to it. Is it possible to default or simply ignore it ?
The way to deal with this in Kotlin is good old-fashioned polymorphism.
The Kotlin serialization library gives you all you need (and I doubt very much whether you will need JsonTransformingSerializer
or to write too much clunky code).
The library is geared around automatically producing serialized forms of Kotlin objects for you, and providing the code to serialize and deserialize. For the most part, you should just be able to write normal Kotlin objects and the serialization library will do the hard work for you.
Read the Serialization Guide: it is a well-written piece of documentation.
For your JSON responses, you can do the following:
abstract
properties here);val
so they become class properties. For the most part, Kotlin types will serialize as JSON types as you expect;@Serializable
;SerialName
annotation so that the subclasses produce the correct type
discriminant on serialization;encodeToString
on some sample objects to see if the JSON output matches what you want. Call encodeToString
with the base class type as it is in the base class scope you wish to use the serialization (ie all the JSON responses are of the base class type). (It's also a great opportunity to do a bit of test-driven development); andIt's a nice library to use once you get the hang of it and I think you will find satisfaction getting it all to work together.
1Making the base class sealed means the library can automatically find the subclasses when you serialize or deserialize the base class. Otherwise, you need to register the subclasses.