| name | kotlinx-serialization-kmp |
| description | Using `kotlinx.serialization` in a KMP codebase — JSON config, polymorphism, sealed hierarchies, custom serializers, and CBOR/ProtoBuf. Use when defining DTOs or wire formats. |
kotlinx.serialization in KMP
Instructions
kotlinx.serialization is the default serializer across KMP: compile-time safe, reflection-free, supports JSON/CBOR/ProtoBuf.
1. Setup
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin.sourceSets.commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.cbor)
implementation(libs.kotlinx.serialization.protobuf)
}
2. Standard DTOs
@Serializable
data class UserDto(
val id: Long,
@SerialName("full_name") val fullName: String,
val email: String,
val roles: List<String> = emptyList(),
@SerialName("created_at") val createdAt: Instant,
)
A single Json instance, shared and reused:
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = false
isLenient = false
coerceInputValues = true
useAlternativeNames = true
prettyPrint = false
}
3. Sealed hierarchies (polymorphism)
@Serializable
@JsonClassDiscriminator("type")
sealed interface Event {
@Serializable @SerialName("login")
data class Login(val userId: Long) : Event
@Serializable @SerialName("logout")
data object Logout : Event
@Serializable @SerialName("purchase")
data class Purchase(val sku: String, val amountCents: Long) : Event
}
JSON:
{ "type": "login", "userId": 42 }
For open polymorphism (non-sealed), register with a SerializersModule:
val module = SerializersModule {
polymorphic(Notification::class) {
subclass(Notification.Email::class)
subclass(Notification.Push::class)
}
}
val json = Json { serializersModule = module; classDiscriminator = "kind" }
4. Custom serializers
For types without @Serializable (e.g. value classes wrapping third-party types):
object ColorAsHexSerializer : KSerializer<Color> {
override val descriptor = PrimitiveSerialDescriptor("Color", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Color) =
encoder.encodeString("#%06X".format(value.rgb and 0xFFFFFF))
override fun deserialize(decoder: Decoder): Color =
Color(decoder.decodeString().removePrefix("#").toInt(16))
}
@Serializable
data class Theme(
@Serializable(with = ColorAsHexSerializer::class) val accent: Color,
)
For composite (object) custom serializers, delegate to an auto-generated descriptor:
@Serializable
private data class MoneySurrogate(val amount: Long, val currency: String)
object MoneySerializer : KSerializer<Money> {
override val descriptor = MoneySurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: Money) =
encoder.encodeSerializableValue(MoneySurrogate.serializer(),
MoneySurrogate(value.amountCents, value.currency))
override fun deserialize(decoder: Decoder): Money =
decoder.decodeSerializableValue(MoneySurrogate.serializer())
.let { Money(it.amount, it.currency) }
}
5. Enums
@Serializable
enum class Status {
@SerialName("active") ACTIVE,
@SerialName("suspended") SUSPENDED,
@SerialName("deleted") DELETED,
}
Use coerceInputValues = true so an unknown "status": "banned" on a nullable field becomes null instead of throwing — but prefer defining the enum fully.
6. Domain ↔ DTO mapping
Keep DTOs in data/ and domain entities in domain/. Never serialize domain directly — it couples the wire format to the model.
fun UserDto.toDomain() = User(id = id, name = fullName, email = email, createdAt = createdAt)
fun User.toDto() = UserDto(id = id, fullName = name, email = email, createdAt = createdAt)
7. CBOR / ProtoBuf
For compact binary (watch companion, BLE payloads):
val cbor = Cbor { ignoreUnknownKeys = true }
val bytes = cbor.encodeToByteArray(Event.Login(42))
val event = cbor.decodeFromByteArray<Event>(bytes)
ProtoBuf (requires @ProtoNumber on every field for stable wire format):
@Serializable
data class Ping(
@ProtoNumber(1) val id: Long,
@ProtoNumber(2) val timestamp: Long,
)
8. Common gotchas
- Make every class in the hierarchy
@Serializable — missing annotations surface as cryptic runtime errors on Kotlin/Native.
data object subtypes of a sealed interface need @Serializable @SerialName("…") just like data classes.
Instant / LocalDate need kotlinx-datetime-serialization (auto-wired when kotlinx.datetime is on the classpath as of 0.6+).
- Keep the
Json { } instance in a singleton — constructing it per call is expensive on iOS.
Checklist