JSON (JavaScript Object Notation) adalah format pertukaran data paling populer untuk REST API, file konfigurasi, dan message queue. Meskipun Kotlin memiliki pustaka resmi kotlinx.serialization untuk produksi, memahami cara mem-parsing JSON secara manual memberi wawasan tentang bagaimana parser bekerja dan kapan menggunakan pustaka eksternal.

Struktur JSON #

JSON mendukung enam tipe data:

TipeSintaksContoh
Object{ "k": v, ... }{"nama": "Budi"}
Array[v, v, ...][1, 2, 3]
String"..." (escape \n, \", \\)"halo\n"
Numberinteger atau float42, 3.14
Booleantrue / falsetrue
Nullnullnull

JSON tidak mendukung komentar, koma trailing, atau string tunggal — kesalahan umum yang membuat parser ketat menolak file.

Parser JSON Manual di Kotlin #

Sebelum membahas pustaka eksternal, mari kita tulis parser JSON sederhana menggunakan sealed class dan recursive descent. Pendekatan ini mengajarkan struktur data JSON dan teknik parsing yang sama dipakai oleh library profesional.

kotlin
import java.io.StringReader
import java.io.StringWriter

// === Parser JSON sederhana tanpa library eksternal ===
// Mendukung: object { "k": v }, array [v, v], string, number, boolean, null

sealed class JsonValue {
    data class Str(val v: String) : JsonValue() {
        override fun toString() = "\"$v\""
    }
    data class Num(val v: Double) : JsonValue() {
        override fun toString() = if (v % 1.0 == 0.0) v.toInt().toString() else v.toString()
    }
    data class Bool(val v: Boolean) : JsonValue() {
        override fun toString() = v.toString()
    }
    object Null : JsonValue() {
        override fun toString() = "null"
    }
    data class Arr(val items: List<JsonValue>) : JsonValue() {
        override fun toString() = items.joinToString(", ", "[", "]")
    }
    data class Obj(val entries: List<Pair<String, JsonValue>>) : JsonValue() {
        override fun toString() = entries.joinToString(", ") { "\"${it.first}\": ${it.second}" }.let { "{$it}" }
    }
}

class JsonParser(private val src: String) {
    private var pos = 0

    fun parse(): JsonValue {
        skipWs()
        val v = parseValue()
        skipWs()
        require(pos == src.length) { "Karakter tersisa setelah JSON valid" }
        return v
    }

    private fun parseValue(): JsonValue {
        skipWs()
        return when (val c = peek()) {
            '"' -> JsonValue.Str(parseString())
            't', 'f' -> parseBool()
            'n' -> parseNull()
            '[' -> parseArray()
            '{' -> parseObject()
            else -> if (c == '-' || c.isDigit()) parseNumber() else throw IllegalArgumentException("Unexpected: $c at $pos")
        }
    }

    private fun parseString(): String {
        require(peek() == '"')
        pos++
        val sb = StringBuilder()
        while (peek() != '"') {
            val c = src[pos++]
            if (c == '\\') {
                when (val e = src[pos++]) {
                    'n' -> sb.append('\n')
                    't' -> sb.append('\t')
                    '"' -> sb.append('"')
                    '\\' -> sb.append('\\')
                    '/' -> sb.append('/')
                    else -> sb.append(e)
                }
            } else sb.append(c)
        }
        pos++
        return sb.toString()
    }

    private fun parseNumber(): JsonValue.Num {
        val start = pos
        if (peek() == '-') pos++
        while (pos < src.length && (src[pos].isDigit() || src[pos] == '.' || src[pos] == 'e' || src[pos] == 'E' || src[pos] == '+' || src[pos] == '-')) pos++
        return JsonValue.Num(src.substring(start, pos).toDouble())
    }

    private fun parseBool(): JsonValue.Bool {
        if (src.startsWith("true", pos)) { pos += 4; return JsonValue.Bool(true) }
        if (src.startsWith("false", pos)) { pos += 5; return JsonValue.Bool(false) }
        throw IllegalArgumentException("Invalid bool at $pos")
    }

    private fun parseNull(): JsonValue.Null {
        if (src.startsWith("null", pos)) { pos += 4; return JsonValue.Null }
        throw IllegalArgumentException("Invalid null at $pos")
    }

    private fun parseArray(): JsonValue.Arr {
        require(peek() == '['); pos++
        skipWs()
        val items = mutableListOf<JsonValue>()
        if (peek() == ']') { pos++; return JsonValue.Arr(items) }
        while (true) {
            items.add(parseValue())
            skipWs()
            when (peek()) {
                ',' -> { pos++; skipWs() }
                ']' -> { pos++; return JsonValue.Arr(items) }
                else -> throw IllegalArgumentException("Expected , or ] at $pos")
            }
        }
    }

    private fun parseObject(): JsonValue.Obj {
        require(peek() == '{'); pos++
        skipWs()
        val entries = mutableListOf<Pair<String, JsonValue>>()
        if (peek() == '}') { pos++; return JsonValue.Obj(entries) }
        while (true) {
            skipWs()
            val key = parseString()
            skipWs()
            require(peek() == ':'); pos++
            val value = parseValue()
            entries.add(key to value)
            skipWs()
            when (peek()) {
                ',' -> { pos++; skipWs() }
                '}' -> { pos++; return JsonValue.Obj(entries) }
                else -> throw IllegalArgumentException("Expected , or } at $pos")
            }
        }
    }

    private fun peek(): Char = src[pos]
    private fun skipWs() { while (pos < src.length && src[pos].isWhitespace()) pos++ }
}

fun main() {
    val jsonText = """
        {
            "nama": "Budi",
            "umur": 25,
            "menikah": false,
            "anak": ["Andi", "Citra"],
            "alamat": null
        }
    """.trimIndent()

    val parser = JsonParser(jsonText)
    val parsed = parser.parse()

    println("Parsed JSON:")
    println(parsed)
    println()

    // Akses field
    val obj = parsed as JsonValue.Obj
    for ((key, value) in obj.entries) {
        println("  $key = $value")
    }

    // Cek tipe
    val nama = obj.entries.find { it.first == "nama" }?.second
    println("\nNama: $nama (tipe: ${nama?.javaClass?.simpleName})")

    val anak = obj.entries.find { it.first == "anak" }?.second as JsonValue.Arr
    println("Jumlah anak: ${anak.items.size}")
    println("Anak pertama: ${anak.items[0]}")
}

Output yang Dihasilkan:

text
Parsed JSON:
{"nama": "Budi", "umur": 25, "menikah": false, "anak": ["Andi", "Citra"], "alamat": null}

  nama = "Budi"
  umur = 25
  menikah = false
  anak = ["Andi", "Citra"]
  alamat = null

Nama: "Budi" (tipe: Str)
Jumlah anak: 2
Anak pertama: "Andi"

Mari kita bedah bagian-bagian penting parser di atas:

  1. sealed class JsonValue: tipe data algebraic yang merepresentasikan enam tipe JSON. sealed membuat when exhaustive — compiler memaksa kita menangani semua kasus.
  2. data class Str/Num/Bool/Arr/Obj: subclass dengan field terstruktur. data class otomatis menghasilkan equals, hashCode, dan toString.
  3. object Null: singleton karena null tidak punya state.
  4. JsonParser(src: String): menyimpan string sumber dan posisi parser saat ini (pos).
  5. parseValue(): dispatch ke parser yang sesuai berdasarkan karakter pertama. Ini adalah inti recursive descent.
  6. parseString(): membaca karakter demi karakter, mendekode escape sequence seperti \n, \", \\.
  7. parseObject(): loop membaca key-value pair hingga }. Setiap key harus berupa string, value bisa tipe apa pun.
  8. parseArray(): loop membaca value hingga ].
  9. skipWs(): melewati whitespace (spasi, tab, newline) — JSON mengizinkan whitespace di mana pun kecuali di dalam string.

Mengapa Memakai Library untuk Produksi #

Parser manual di atas adalah ilustrasi — produksi memerlukan fitur yang lebih lengkap:

  • Unicode escape: \u0041 untuk huruf “A”.
  • Presisi angka: BigInteger untuk angka besar, BigDecimal untuk angka desimal.
  • Streaming parsing: membaca file JSON besar tanpa memuat seluruhnya ke memori.
  • Schema validation: memastikan field wajib ada dan tipe sesuai kontrak.
  • Error reporting: posisi baris dan kolom, bukan hanya offset karakter.

kotlinx.serialization #

Untuk produksi, Kotlin menyediakan kotlinx.serialization:

kotlin
import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class Pengguna(val nama: String, val umur: Int)

fun main() {
    val u = Pengguna("Budi", 25)
    val json = Json.encodeToString(u)
    println(json) // {"nama":"Budi","umur":25}

    val loaded = Json.decodeFromString<Pengguna>(json)
    println(loaded)
}

Library ini bersifat compiler-aware: anotasi @Serializable memicu compiler menghasilkan serializer otomatis saat build. Tidak ada reflection runtime, sehingga aman untuk Kotlin/Native dan Kotlin/JS.

Best Practice #

  • Selalu gunakan pustaka yang teruji (kotlinx.serialization, Moshi, Gson) untuk produksi.
  • Tetapkan schema JSON eksplisit dengan @Serializable dan field wajib.
  • Tangani MissingFieldException dengan baik — ini mengindikasikan data tidak sesuai kontrak.
  • Untuk JSON dari sumber tidak terpercaya, batasi ukuran maksimum dan timeout parser untuk mencegah serangan DoS.

Referensi Lanjutan #

Selanjutnya #

Dengan JSON di tangan, kita dapat membangun pipeline file yang robust — menggabungkan baca, tulis, validasi, dan error handling. Materi ini akan dibahas pada chapter Robust File Processing.