kotlinx.serialization adalah library serialisasi resmi Kotlin yang menghasilkan kode serializer pada waktu kompilasi. Berbeda dengan refleksi runtime, serializer yang dihasilkan kompilator cepat, aman terhadap perubahan tipe, dan tidak memerlukan reflect. Pada chapter ini kita akan memahami cara kerja @Serializable, format Json singleton, dan round-trip objek Kotlin ke JSON. Karena kotlinx.serialization nyata membutuhkan kompilator plugin, kita akan memodelkannya dengan anotasi marker dan serializer manual.

Anotasi @Serializable #

Anotasi @Serializable menandai data class sebagai kandidat serialisasi. Saat kompilator memproses class tersebut, ia menghasilkan NamaClass$$serializer — objek yang mengimplementasi KSerializer<T>. Serializer ini tahu urutan field, tipe, dan cara encode/decode tanpa refleksi runtime.

kotlin @Serializable data class
@Serializable
data class Produk(val id: Int, val nama: String, val harga: Int, val stok: Int)

Class yang ditandai @Serializable otomatis dapat dipakai dengan Json.encodeToString(produk) dan Json.decodeFromString<Produk>(json). Tipe primitif (Int, String, Boolean, Long, Double) didukung langsung. Tipe kompleks seperti LocalDate memerlukan serializer kustom yang didaftarkan ke Json configuration.

Format Json Singleton #

Objek Json adalah entry point ke serializer. Konfigurasi umum meliputi ignoreUnknownKeys (lewati field JSON yang tidak ada di class), prettyPrint (indentasi untuk keterbacaan), encodeDefaults (sertakan field dengan nilai default). Konfigurasi ini diatur saat pembuatan instance Json { ... }.

kotlin Json configuration
val json = Json {
    ignoreUnknownKeys = true
    prettyPrint = true
    encodeDefaults = true
}
val jsonString = json.encodeToString(produk)

Pada kode produksi, satu instance Json biasanya dibuat sebagai singleton global agar konfigurasi konsisten.

Round-trip Serialize-Deserialize #

Round-trip berarti mengonversi objek ke JSON lalu kembali ke objek — dan memverifikasi kedua objek identik. Hal ini menguji bahwa serializer dan deserializer konsisten. Untuk data class Kotlin, round-trip umumnya aman karena field disimpan berdasarkan nama.

Demo kotlinx.serialization #

Karena kompilator plugin tidak tersedia di sandbox ini, kita akan memodelkan @Serializable sebagai anotasi marker dan menulis serializer manual yang meniru perilaku serializer yang dihasilkan kompilator. Pola ini memperlihatkan kontrak KSerializer<T> dengan jelas.

kotlin
// ktor_serialization.kt
// Mendemonstrasikan kotlinx.serialization: @Serializable data class
// otomatis dikonversi ke JSON. Pada kode produksi, kompilator plugin
// kotlinx.serialization menghasilkan serializer. Di sini kita memakai
// anotasi marker + serializer manual berbasis refleksi sederhana.

// Mock @Serializable annotation (di produksi: kotlinx.serialization.Serializable)
@Target(AnnotationTarget.CLASS)
annotation class Serializable

// Antarmuka serializer yang dihasilkan oleh plugin
interface KSerializer<T> {
    val descriptor: String
    fun serialize(value: T): String
    fun deserialize(text: String): T
}

@Serializable
data class Produk(val id: Int, val nama: String, val harga: Int, val stok: Int)

@Serializable
data class Pesanan(val id: Int, val produkId: Int, val jumlah: Int, val alamat: String)

// Serializer manual untuk Produk (mensimulasikan yang dihasilkan plugin)
object ProdukSerializer : KSerializer<Produk> {
    override val descriptor = "Produk(id:Int,nama:String,harga:Int,stok:Int)"
    override fun serialize(value: Produk): String =
        """{"id":${value.id},"nama":"${value.nama}","harga":${value.harga},"stok":${value.stok}}"""
    override fun deserialize(text: String): Produk {
        val id = Regex(""""id":(\d+)""").find(text)?.groupValues?.get(1)?.toInt() ?: 0
        val nama = Regex(""""nama":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: ""
        val harga = Regex(""""harga":(\d+)""").find(text)?.groupValues?.get(1)?.toInt() ?: 0
        val stok = Regex(""""stok":(\d+)""").find(text)?.groupValues?.get(1)?.toInt() ?: 0
        return Produk(id, nama, harga, stok)
    }
}

object PesananSerializer : KSerializer<Pesanan> {
    override val descriptor = "Pesanan(id:Int,produkId:Int,jumlah:Int,alamat:String)"
    override fun serialize(value: Pesanan): String =
        """{"id":${value.id},"produkId":${value.produkId},"jumlah":${value.jumlah},"alamat":"${value.alamat}"}"""
    override fun deserialize(text: String): Pesanan {
        val id = Regex(""""id":(\d+)""").find(text)?.groupValues?.get(1)?.toInt() ?: 0
        val pid = Regex(""""produkId":(\d+)""").find(text)?.groupValues?.get(1)?.toInt() ?: 0
        val jml = Regex(""""jumlah":(\d+)""").find(text)?.groupValues?.get(1)?.toInt() ?: 0
        val alamat = Regex(""""alamat":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: ""
        return Pesanan(id, pid, jml, alamat)
    }
}

// Format singleton (mirip Json.encodeToString / decodeFromString)
object Json {
    private val serializers = mutableMapOf<String, KSerializer<*>>()

    fun register(serializer: KSerializer<*>) {
        val nama = serializer.descriptor.substringBefore("(")
        serializers[nama] = serializer
    }

    @PublishedApi
    internal fun findSerializer(nama: String): KSerializer<*> =
        serializers[nama] ?: throw IllegalStateException("Tidak ada serializer untuk $nama")

    inline fun <reified T> encodeToString(value: T): String {
        val nama = T::class.simpleName ?: ""
        @Suppress("UNCHECKED_CAST")
        val serializer = findSerializer(nama) as KSerializer<T>
        return serializer.serialize(value)
    }

    inline fun <reified T> decodeFromString(text: String): T {
        val nama = T::class.simpleName ?: ""
        @Suppress("UNCHECKED_CAST")
        val serializer = findSerializer(nama) as KSerializer<T>
        return serializer.deserialize(text)
    }
}

fun main() {
    Json.register(ProdukSerializer)
    Json.register(PesananSerializer)

    println("=== Serialize Produk ===")
    val kopi = Produk(1, "Kopi Tubruk", 15000, 100)
    val json1 = Json.encodeToString(kopi)
    println(json1)

    println("\n=== Deserialize Produk ===")
    val parsed: Produk = Json.decodeFromString(json1)
    println(parsed)

    println("\n=== Serialize Pesanan ===")
    val pesanan = Pesanan(10, 1, 3, "Jl. Mawar No. 1")
    val json2 = Json.encodeToString(pesanan)
    println(json2)

    println("\n=== Deserialize Pesanan ===")
    val parsedPesanan: Pesanan = Json.decodeFromString(json2)
    println(parsedPesanan)

    println("\n=== Round-trip verification ===")
    println("Identik: ${kopi == parsed}")
    println("Identik: ${pesanan == parsedPesanan}")
}

Output yang Dihasilkan:

text
=== Serialize Produk ===
{"id":1,"nama":"Kopi Tubruk","harga":15000,"stok":100}

=== Deserialize Produk ===
Produk(id=1, nama=Kopi Tubruk, harga=15000, stok=100)

=== Serialize Pesanan ===
{"id":10,"produkId":1,"jumlah":3,"alamat":"Jl. Mawar No. 1"}

=== Deserialize Pesanan ===
Pesanan(id=10, produkId=1, jumlah=3, alamat=Jl. Mawar No. 1)

=== Round-trip verification ===
Identik: true
Identik: true

Mari kita bedah bagian penting kode di atas:

  1. @Target(AnnotationTarget.CLASS) annotation class Serializable: anotasi marker tanpa parameter. Pada produksi, kotlinx.serialization.Serializable juga sebuah anotasi — tetapi memicu kompilator plugin untuk menghasilkan serializer.
  2. interface KSerializer<T> { val descriptor; fun serialize; fun deserialize }: kontrak serializer dengan descriptor yang mendeskripsikan struktur. Descriptor dipakai untuk debugging dan untuk format yang membutuhkan schema (mis. ProtoBuf).
  3. inline fun <reified T> encodeToString(value: T): function reified agar T::class.simpleName dapat diakses pada runtime. Inilah trik yang membuat API terlihat seperti Json.encodeToString(produk) tanpa argumen tipe eksplisit.
  4. Json.register(ProdukSerializer): pendaftaran serializer manual. Pada produksi, kompilator plugin mendaftarkan serializer otomatis — pengembang tidak perlu memanggil register sama sekali.
  5. println("Identik: ${kopi == parsed}"): verifikasi round-trip. Data class Kotlin menghasilkan equals berbasis seluruh field, sehingga perbandingan ini akurat.

Konfigurasi Penting #

Beberapa konfigurasi Json yang sering dipakai di backend:

OpsiPerilaku
ignoreUnknownKeys = trueLewati field JSON yang tidak ada di class
prettyPrint = trueIndentasi untuk keterbacaan
encodeDefaults = trueSertakan field dengan nilai default
explicitNulls = falseHilangkan field null dari output
coerceInputValues = trueCoerce nilai invalid ke default

Konfigurasi ignoreUnknownKeys = true sangat penting untuk kompatibilitas versi — klien lama yang tidak tahu field baru tidak akan rusak.

Referensi Lanjutan #

  • Ktor serialization — panduan resmi konversi objek Kotlin ke JSON lewat kotlinx.serialization di Ktor
  • Ktor content negotiation — panduan resmi plugin ContentNegotiation yang menghubungkan body HTTP dengan serializer

Selanjutnya #

Setelah memahami serialisasi, langkah berikutnya adalah memvalidasi input yang sudah ter-deserialize. Chapter Ktor Validation akan membahas validator kustom, pengumpulan error multi-field, dan format respons error mengikuti RFC 7807 Problem Details.