To control the structure and content of the JSON generated during serialization, you can create custom serializers. For smaller adjustments, such as wrapping values or unwrapping arrays, the JsonTransformingSerializer class provides a simpler way to modify JSON by working directly with the JSON element tree instead of using the Encoder or Decoder manually.
The JsonTransformingSerializer is a JSON-specific abstract serializer that implements the KSerializer interface. It provides the transformSerialize() and transformDeserialize() functions that you can override to adjust the JSON element tree before serialization or deserialization.
You can adjust the JSON structure by transforming the JSON element tree. The following examples demonstrate common use cases, such as wrapping or unwrapping arrays and omitting specific properties.
Wrap a single object in an array during deserialization
Some APIs return a single JSON object for one item and a JSON array for multiple items. To deserialize both cases into a List:
Create a subclass of JsonTransformingSerializer and specify a serializer in its constructor. To use the standard conversion logic, pass the default serializer for the target type, such as ListSerializer() for lists.
Override the transformDeserialize() function.
Here's an example:
// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(
val name: String,
// Specifies UserListSerializer to handle the serialization of the users property
@Serializable(with = UserListSerializer::class)
val users: List<User>
)
@Serializable
data class User(val name: String)
// Creates a serializer that transforms the results of the default List serializer
object UserListSerializer : JsonTransformingSerializer<List<User>>(ListSerializer(User.serializer())) {
override fun transformDeserialize(element: JsonElement): JsonElement =
// If the element is not a JsonArray, wraps it into a single-element array
if (element !is JsonArray) JsonArray(listOf(element)) else element
}
fun main() {
println(Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization","users":{"name":"kotlin"}}
"""))
// Project(name=kotlinx.serialization, users=[User(name=kotlin)])
println(Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization","users":[{"name":"kotlin"},{"name":"jetbrains"}]}
"""))
// Project(name=kotlinx.serialization, users=[User(name=kotlin), User(name=jetbrains)])
}
//sampleEnd
Unwrap a single-element array during serialization
To unwrap a single-element list into a single JSON object during serialization, override the transformSerialize() function:
// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(
val name: String,
// Specifies UserListSerializer to handle serialization of the users property
@Serializable(with = UserListSerializer::class)
val users: List<User>
)
@Serializable
data class User(val name: String)
// Creates a serializer that transforms the results of the default List serializer
object UserListSerializer : JsonTransformingSerializer<List<User>>(ListSerializer(User.serializer())) {
override fun transformSerialize(element: JsonElement): JsonElement {
require(element is JsonArray)
// Unwraps single-element lists into a single JSON object
return element.singleOrNull() ?: element
}
}
fun main() {
val data = Project("kotlinx.serialization", listOf(User("kotlin")))
println(Json.encodeToString(data))
// {"name":"kotlinx.serialization","users":{"name":"kotlin"}}
}
//sampleEnd
Omit specific properties during serialization
If you can't specify a default value, but still want to omit a property when it has a specific value, use JsonTransformingSerializer:
Create a subclass of JsonTransformingSerializer and specify a serializer in its constructor.
Override the transformSerialize() function.
Here's an example where the Project class has a language property, which is omitted when its value is "Kotlin":
// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
class Project(val name: String, val language: String)
// Creates a custom serializer that omits the language property if it's equal to "Kotlin"
object ProjectSerializer : JsonTransformingSerializer<Project>(Project.serializer()) {
override fun transformSerialize(element: JsonElement): JsonElement =
// Omits the language property if its value is "Kotlin"
JsonObject(element.jsonObject.filterNot {
(k, v) -> k == "language" && v.jsonPrimitive.content == "Kotlin"
})
}
fun main() {
val data = Project("kotlinx.serialization", "Kotlin")
// Uses the default serializer
println(Json.encodeToString(data))
// {"name":"kotlinx.serialization","language":"Kotlin"}
// Applies the custom serializer to omit the language property
println(Json.encodeToString(ProjectSerializer, data))
// {"name":"kotlinx.serialization"}
}
//sampleEnd
Select the appropriate polymorphic class based on the JSON content
In polymorphic serialization, JSON often contains a dedicated class discriminator property that identifies the concrete subtype during deserialization.
If the JSON input doesn't contain a class discriminator, you can use JsonContentPolymorphicSerializer to infer the type from the structure of the JSON. This serializer lets you override the selectDeserializer() function to choose the correct deserializer based on the JSON content.
Here's an example where all values share a common base type with a name property:
@Serializable
abstract class Project {
abstract val name: String
}
@Serializable
data class BasicProject(override val name: String): Project()
@Serializable
data class OwnedProject(override val name: String, val owner: String) : Project()
To distinguish between BasicProject and OwnedProject, override the selectDeserializer() function. You can use this function to check whether the JSON object contains the owner key, and return the corresponding serializer:
// Creates a custom serializer that selects deserializer based on the presence of "owner"
object ProjectSerializer : JsonContentPolymorphicSerializer<Project>(Project::class) {
override fun selectDeserializer(element: JsonElement) = when {
// Selects the OwnedProject serializer if the JSON object contains an "owner" key
"owner" in element.jsonObject -> OwnedProject.serializer()
else -> BasicProject.serializer()
}
}
When you use this serializer to serialize data, Kotlin serialization uses the serializer of the value's actual runtime type. This could be the serializer specified in a SerializersModule or the default serializer:
// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
@Serializable
abstract class Project {
abstract val name: String
}
@Serializable
data class BasicProject(override val name: String): Project()
@Serializable
data class OwnedProject(override val name: String, val owner: String) : Project()
// Creates a custom serializer that selects deserializer based on the presence of "owner"
object ProjectSerializer : JsonContentPolymorphicSerializer<Project>(Project::class) {
override fun selectDeserializer(element: JsonElement) = when {
// Selects the OwnedProject serializer if the JSON object contains an "owner" key
"owner" in element.jsonObject -> OwnedProject.serializer()
else -> BasicProject.serializer()
}
}
//sampleStart
fun main() {
val data = listOf(
OwnedProject("kotlinx.serialization", "kotlin"),
BasicProject("example")
)
// No class discriminator in the JSON output
val string = Json.encodeToString(ListSerializer(ProjectSerializer), data)
println(string)
// [{"name":"kotlinx.serialization","owner":"kotlin"},{"name":"example"}]
println(Json.decodeFromString(ListSerializer(ProjectSerializer), string))
// [OwnedProject(name=kotlinx.serialization, owner=kotlin), BasicProject(name=example)]
}
//sampleEnd
Add custom behavior to the default serializer
You can add custom behavior to the default serializer that Kotlin serialization generates, by using the default serializer as a delegate.
To do so, annotate a serializable class with the Experimental@KeepGeneratedSerializer and use the automatically generated generatedSerializer() as the base serializer in your custom JsonTransformingSerializer.
Here's an example that updates the JSON structure during deserialization by combining multiple input properties into a single name property expected by the target class:
// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = UserNameSerializer::class)
// Defines a type with a name property
data class User(val name: String)
// Adds custom logic to the default serializer to combine input properties during deserialization
object UserNameSerializer : JsonTransformingSerializer<User>(User.generatedSerializer()) {
override fun transformDeserialize(element: JsonElement): JsonElement {
val jsonObject = element.jsonObject
val first = jsonObject["firstName"]?.jsonPrimitive?.content
val last = jsonObject["lastName"]?.jsonPrimitive?.content
// Combines input properties into the name property
// if the input doesn't match the expected structure
return if (first != null && last != null) {
JsonObject(mapOf("name" to JsonPrimitive("$first $last")))
} else {
jsonObject
}
}
}
fun main() {
// Deserializes JSON where the name property is split across multiple input properties
val fromExternalData = Json.decodeFromString<User>(
"""{"firstName":"John","lastName":"Smith"}"""
)
println(fromExternalData)
// User(name=John Smith)
// Deserializes JSON where the name property matches the expected structure
val fromInternalData = Json.decodeFromString<User>(
"""{"name":"John Smith"}"""
)
println(fromInternalData)
// User(name=John Smith)
}
//sampleEnd
Implement custom serialization logic in JSON
If the transformation functions provided by JsonTransformingSerializer or JsonContentPolymorphicSerializer aren't enough, you can implement custom serialization logic by defining your own KSerializer class.
You can get full control over how values are serialized and deserialized by overriding the serialize() and deserialize() functions directly.
When you implement custom serialization logic for JSON, you can cast Encoder to JsonEncoder and Decoder to JsonDecoder to call the JSON-specific functions decodeJsonElement() and encodeToJsonElement(). These functions allow you to retrieve JSON elements from the value the decoder is currently handling, or insert JSON elements into it.
Both JsonDecoder and JsonEncoder expose a json property that gives access to the active Json instance, which controls how values are encoded and decoded. Through that instance, you can use encodeToJsonElement() and decodeFromJsonElement() to convert between JsonElement instances and Kotlin objects.
Using these APIs, you can implement two-stage conversions:
Decode the input into a JsonElement first and then convert that element into a Kotlin value.
Convert a Kotlin value into a JsonElement first and then encode that element with the encoder.
Let's look at an example of a custom KSerializer that fully controls how values of a Response type are encoded and decoded in JSON. This serializer encodes an Ok response directly as a JSON value and an Error response as a JSON object that contains the error message:
// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
// Defines a sealed class for API responses
@Serializable(with = ResponseSerializer::class)
sealed class Response<out T> {
data class Ok<out T>(val data: T) : Response<T>()
data class Error(val message: String) : Response<Nothing>()
}
// Implements custom serialization logic for Response
class ResponseSerializer<T>(
private val dataSerializer: KSerializer<T>
) : KSerializer<Response<T>> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Response") {
element("Ok", dataSerializer.descriptor)
element("Error", buildClassSerialDescriptor("Error") {
element<String>("message")
})
}
// Deserializes a Response value from JSON
override fun deserialize(decoder: Decoder): Response<T> {
// Ensures that the decoder is a JsonDecoder
require(decoder is JsonDecoder)
// Decodes the input into a JsonElement
val element = decoder.decodeJsonElement()
// Converts the JsonElement into the corresponding Response value
return if (element is JsonObject && "error" in element) {
Response.Error(element["error"]!!.jsonPrimitive.content)
} else {
Response.Ok(
decoder.json.decodeFromJsonElement(dataSerializer, element)
)
}
}
// Serializes a Response value to JSON
override fun serialize(encoder: Encoder, value: Response<T>) {
// Ensures that the encoder is a JsonEncoder
require(encoder is JsonEncoder)
// Converts the Response value into a JsonElement
val element = when (value) {
is Response.Ok ->
encoder.json.encodeToJsonElement(dataSerializer, value.data)
is Response.Error ->
buildJsonObject { put("error", value.message) }
}
// Encodes the JsonElement using the encoder
encoder.encodeJsonElement(element)
}
}
@Serializable
data class Project(val name: String)
fun main() {
val responses = listOf(
Response.Ok(Project("kotlinx.serialization")),
Response.Error("Not found")
)
val json = Json.encodeToString(responses)
println(json)
// [{"name":"kotlinx.serialization"},{"error":"Not found"}]
println(Json.decodeFromString<List<Response<Project>>>(json))
// [Ok(data=Project(name=kotlinx.serialization)), Error(message=Not found)]
}
Preserve unknown JSON attributes
A common use case for a custom JSON-specific serializer is preserving JSON properties from the input that your serializable class doesn't define. By default, these properties are ignored during deserialization.
To preserve these JSON properties, implement a custom JSON-specific serializer that collects all properties not defined in the target class into a dedicated JsonObject field during deserialization. This allows you to preserve these properties in the serializable class without modifying the original JSON structure.
Here's an example:
// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
//sampleStart
data class UnknownProject(val name: String, val details: JsonObject)
object UnknownProjectSerializer : KSerializer<UnknownProject> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("UnknownProject") {
element<String>("name")
element<JsonElement>("details")
}
override fun deserialize(decoder: Decoder): UnknownProject {
// Ensures the decoder is JSON-specific
val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON")
// Reads the entire content as JSON
val json = jsonInput.decodeJsonElement().jsonObject
// Extracts and removes the name property
val name = json.getValue("name").jsonPrimitive.content
val details = json.toMutableMap()
details.remove("name")
return UnknownProject(name, JsonObject(details))
}
override fun serialize(encoder: Encoder, value: UnknownProject) {
error("Serialization is not supported")
}
}
fun main() {
// Deserializes JSON with properties not defined in the serializable class into UnknownProject
println(Json.decodeFromString(UnknownProjectSerializer, """{"type":"unknown","name":"example","maintainer":"Unknown","license":"Apache 2.0"}"""))
// UnknownProject(name=example, details={"type":"unknown","maintainer":"Unknown","license":"Apache 2.0"})
}
//sampleEnd
In this example, the preserved JSON properties remain at the same level within the input JSON object as the properties defined in the serializable class.