Serialization isn’t just about libraries like kotlinx.serialization or Jackson; it’s intertwined with Kotlin’s syntax and semantics. In this post, we’ll explore how Kotlin’s design choices impact serialization, with real-world implications and constraints. I’ll keep things clear, provide full code examples, and assume you’re comfortable with Kotlin basics but want to level up on advanced topics.
We’ll aim for around 3000 words, so grab a coffee—this is going to be comprehensive but practical.
Serialization as a Language Concern
Serialization is the process of converting an object’s state into a format that can be stored or transmitted—think JSON for APIs, protobuf for gRPC, or even custom binary formats. In Kotlin, this isn’t just a library feature; it’s a language concern because Kotlin’s type system, constructors, and immutability principles directly influence how serialization works.
Why does this matter? Kotlin is designed for conciseness and safety, with features like null safety, data classes, and sealed hierarchies. But when serializing, we often deal with external formats that don’t respect these rules. For instance, JSON doesn’t have Kotlin’s nullability baked in, so mismatches can lead to runtime errors.
Popular libraries handle this differently:
- kotlinx.serialization: Official, compile-time safe, uses annotations like @Serializable.
- Jackson/Gson: Reflection-based, more flexible but runtime-heavy.
- Moshi: Similar to Gson but with Kotlin adapters.
As a mid-level dev, you might use these without thinking about language implications. But consider: Kotlin’s properties (var/val) affect how serializers access fields. Data classes auto-generate equals/hashCode/toString, which is great, but serialization might bypass them if not configured properly.
Let’s look at a basic example with kotlinx.serialization. Suppose we have a simple User class:
Kotlin
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
@Serializable
data class User(val id: Int, val name: String)
fun main() {
val user = User(1, "Alice")
val json = Json.encodeToString(user)
println(json) // Output: {"id":1,"name":"Alice"}
val decoded = Json.decodeFromString<User>(json)
println(decoded) // Output: User(id=1, name=Alice)
}
This works seamlessly because data classes align well with serialization—properties are public and match constructor params. But as we’ll see, things get trickier with more Kotlin features.
One key constraint: Serialization often requires a no-arg constructor or default values, which Kotlin doesn’t mandate. Libraries like Jackson use reflection to set fields directly, bypassing constructors, which can violate immutability (more on that later).
In distributed systems, serialization is crucial for backward compatibility. Changing a class can break deserialization if not handled carefully. Kotlin’s evolution (e.g., from inline to value classes) adds to this.
Overall, treating serialization as a language concern means designing classes with it in mind from the start—avoiding pitfalls like cyclic dependencies or non-serializable types.
Primary Constructors and Serialization
Kotlin’s primary constructors are a hallmark of its conciseness. In a data class, properties are declared right in the constructor: data class User(val id: Int, val name: String). This is great for immutability and brevity, but serialization libraries must invoke this constructor during deserialization.
The implication: Serializers need to provide all required parameters. If a field lacks a default, missing data in the input (e.g., JSON) causes errors.
With kotlinx.serialization, it uses the primary constructor by default. If you have secondary constructors, you can annotate them with @Serializable(with = …) for custom logic.
Constraint: Primary constructors can’t have complex logic (like validation) because serializers might not execute the body during deserialization. For validation, use init blocks, but even those run after properties are set.
Example: A User with validation in init.
Kotlin
@Serializable
data class User(val id: Int, val name: String) {
init {
require(id > 0) { "ID must be positive" }
require(name.isNotBlank()) { "Name cannot be blank" }
}
}
fun main() {
val invalidJson = """{"id":0,"name":""}"""
try {
val user = Json.decodeFromString<User>(invalidJson)
} catch (e: IllegalArgumentException) {
println("Validation failed: ${e.message}")
}
}
Here, deserialization sets properties first, then init runs and throws if invalid. This is fine for simple cases but can lead to partially constructed objects if exceptions occur.
For more control, use custom serializers. Suppose we want to handle legacy formats where “username” was used instead of “name”:
Kotlin
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
object UserSerializer : KSerializer<User> {
override val descriptor = buildClassSerialDescriptor("User") {
element<Int>("id")
element<String>("name", isOptional = true)
element<String>("username", isOptional = true) // Legacy
}
override fun serialize(encoder: Encoder, value: User) {
encoder.encodeStructure(descriptor) {
encodeIntElement(descriptor, 0, value.id)
encodeStringElement(descriptor, 1, value.name)
}
}
override fun deserialize(decoder: Decoder): User {
return decoder.decodeStructure(descriptor) {
var id = -1
var name: String? = null
var username: String? = null
loop@ while (true) {
when (val index = decodeElementIndex(descriptor)) {
Decoder.UNKNOWN_NAME -> continue@loop
0 -> id = decodeIntElement(descriptor, 0)
1 -> name = decodeStringElement(descriptor, 1)
2 -> username = decodeStringElement(descriptor, 2)
else -> break@loop
}
}
User(id, name ?: username ?: throw IllegalArgumentException("Name missing"))
}
}
}
@Serializable(with = UserSerializer::class)
data class User(val id: Int, val name: String)
This custom serializer merges legacy fields, showing how primary constructors constrain but can be extended.
In reflection-based libs like Jackson, it uses setters or direct field access, ignoring constructors. This can break if properties are val (immutable)—Jackson needs @JsonSetter or mutable vars.
Key takeaway: Stick to primary constructors for serializable classes to avoid surprises.
Immutability vs Deserialization
Kotlin promotes immutability with val and data classes, which is excellent for thread-safety and reasoning. But deserialization inherently requires mutation: you start with an empty object and set fields one by one.
Implication: Pure immutable classes can’t be deserialized without workarounds. Libraries like kotlinx.serialization generate companion objects with deserialization logic that invokes the constructor with all params at once, preserving immutability.
Constraint: If using reflection (e.g., Gson), it might use temporary mutable states or require vars. This can lead to inconsistent states if deserialization fails midway.
Example: Immutable class with kotlinx.serialization.
Kotlin
@Serializable
data class ImmutableConfig(val apiKey: String, val timeout: Int = 30)
Deserialization works because the serializer collects all values first, then calls the constructor. No mutation needed.
But suppose we have a class with computed properties or dependencies:
Kotlin
@Serializable
data class Person(val firstName: String, val lastName: String) {
val fullName: String
get() = "$firstName $lastName"
}
This is fine—fullName isn’t serialized, as it’s not a constructor param. But if you need to serialize derived fields, use @Transient for non-persisted ones.
For deserialization into mutable objects (e.g., for editing), use vars:
Kotlin
@Serializable
class EditableUser(var id: Int, var name: String)
Here, deserialization sets vars directly. But this sacrifices immutability.
A common pattern: Use immutable data classes for transport, mutable for internal state.
To handle partial deserialization (e.g., streaming), immutability helps avoid race conditions.
In Android Parcelable (a serialization mechanism), immutability is tricky because Parcel requires writing/reading in order, often using mutable builders.
Overall, favor immutability where possible—kotlinx.serialization supports it well—but be aware deserialization might require transient mutability under the hood.
Default Parameters and Binary Compatibility
Default parameters in constructors are a Kotlin superpower: data class User(val id: Int, val active: Boolean = true). This reduces boilerplate.
But for serialization, defaults are crucial for schema evolution. If a field is added with a default, old serialized data can deserialize without it.
Implication: Changing defaults can break binary compatibility. If you change active: Boolean = true to = false, old code deserializing new data might behave differently? No—defaults are compile-time. The issue is ABI (Application Binary Interface).
In Kotlin/JVM, defaults generate extra methods (e.g., constructor with mask for defaults). Changing a default doesn’t break calling existing code, but if serializing, the value is explicit in the output.
Constraint: For binary compat (e.g., in libraries), avoid changing defaults without major version bumps, as clients might rely on them.
Example: Evolving a class.
Initial version:
Kotlin
@Serializable
data class Product(val name: String)
Serialized: {“name”:”Widget”}
Add field with default:
Kotlin
@Serializable
data class Product(val name: String, val price: Double = 0.0)
Can deserialize old JSON—price defaults to 0.0.
But if no default, deserialization fails on old data.
For Jackson, use @JsonInclude(JsonInclude.Include.NON_DEFAULT) to skip defaults in output, aiding compat.
Binary compat tip: Use tools like Binary Compatibility Validator to check changes.
Defaults shine in API evolution but require careful management.
Nullable Fields and Schema Evolution
Kotlin’s null safety (?) is fantastic, but serialization deals with optional fields. Nullable means “can be null,” but in schemas, fields can be missing.
Implication: In JSON, missing != null. kotlinx.serialization distinguishes: non-nullable without default throws on missing; nullable allows null or missing (deserializes to null).
For schema evolution, make new fields nullable or with defaults to handle old data.
Constraint: Changing non-nullable to nullable is safe, but reverse breaks old deserializers.
Example: Evolving User.
Initial:
Kotlin
@Serializable
data class User(val id: Int, val name: String)
Add optional email:
Kotlin
@Serializable
data class User(val id: Int, val name: String, val email: String? = null)
Old JSON deserializes fine, email = null.
If using @Required annotation (in kotlinx), it forces presence.
For schema evolution in protobuf (via kotlinx.serialization.protobuf), nullables aren’t native—use optional wrappers.
Best practice: Use nullables for fields that might be added/removed.
Inline / Value Classes in Serialization
Value classes (formerly inline classes) are zero-overhead wrappers: value class Password(val value: String).
Implication: They serialize as their underlying type, reducing overhead.
Constraint: Can’t have multiple properties; serialization treats them as primitives.
Example:
Kotlin
@Serializable
@JvmInline
value class Email(val address: String)
@Serializable
data class User(val id: Int, val email: Email)
Serialized: {“id”:1,”email”:”alice@example.com”} — email is string, not object.
During deserialization, it wraps back.
For complex logic, custom serializers needed.
Value classes aid performance in serialization-heavy apps, like APIs.
But avoid if needing full object semantics.
Versioning and Backward Compatibility
Versioning ensures old data deserializes into new classes, and vice versa.
Implication: Use explicit versions in data (e.g., @SerialName for renamed fields).
Constraint: Kotlin’s type system doesn’t enforce versioning; it’s manual.
Example: Versioned class with custom serializer handling v1/v2.
Kotlin
@Serializable(with = VersionedUserSerializer::class)
data class User(val id: Int, val name: String, val version: Int = 2) // v2 adds version
object VersionedUserSerializer : KSerializer<User> {
// Descriptor for v2
override val descriptor = buildClassSerialDescriptor("User") {
element<Int>("id")
element<String>("name")
element<Int>("version", isOptional = true)
}
override fun serialize(encoder: Encoder, value: User) {
// Always serialize as v2
encoder.encodeStructure(descriptor) {
encodeIntElement(descriptor, 0, value.id)
encodeStringElement(descriptor, 1, value.name)
encodeIntElement(descriptor, 2, value.version)
}
}
override fun deserialize(decoder: Decoder): User {
return decoder.decodeStructure(descriptor) {
var id = -1
var name = ""
var version = 1 // Assume v1 if missing
loop@ while (true) {
when (val index = decodeElementIndex(descriptor)) {
Decoder.UNKNOWN_NAME -> continue@loop
0 -> id = decodeIntElement(descriptor, 0)
1 -> name = decodeStringElement(descriptor, 1)
2 -> version = decodeIntElement(descriptor, 2)
else -> break@loop
}
}
if (version == 1) {
// Handle v1 specifics, e.g., transform name
User(id, name.uppercase(), 2)
} else {
User(id, name, version)
}
}
}
}
This allows reading v1 (no version) and writing v2.
Tools like Avro or Protobuf have built-in schema registries for this.
Designing Serializable Models Safely
To wrap up, here are best practices:
- Use data classes for simplicity.
- Favor kotlinx.serialization for type safety.
- Add defaults/nullables for evolution.
- Custom serializers for complex cases.
- Test compat: Serialize old, deserialize new, and reverse.
- Avoid cycles, non-serializable types (e.g., functions).
- Document schemas.
Example safe model:
Kotlin
@Serializable
data class SafeProduct(
val id: Int,
val name: String,
val price: Double? = null, // Nullable for evolution
@Transient val computedDiscount: Double = 0.0 // Not serialized
)
By designing with these in mind, you’ll avoid common pitfalls.
