1. Навігація в JsonElement без падінь
Коли ви вперше бачите JSON, хочеться думати про нього як про добре вихований об’єкт: «завжди є id, завжди число, завжди не null». Це відчуття приблизно як віра в те, що Wi‑Fi в аудиторії буде стабільним. Теоретично це можливо, але краще не будувати на цьому диплом.
Проблема в тому, що JSON без схеми часто приходить із зовнішніх джерел, де структура може змінюватися: поле перейменували, тип змінили, вкладеність зсунули, десь додали null, десь видалили ключ. І якщо в коді зробити один необережний рух (наприклад, !! або as), ви отримаєте падіння під час виконання замість зрозумілої й керованої поведінки.
Головна мета сьогоднішньої лекції — виробити звичку «кожен крок — це перевірка» і навчитися пакувати ці перевірки в маленькі helper‑функції, щоб код не перетворювався на ліс із if і return.
is, as? і кроки навігації
Якщо в Kotlin ви робите as JsonObject, то прямо кажете: «Я клянуся, що це об’єкт». Якщо ви помилилися — буде виняток. Якщо ж ви робите as? JsonObject, то кажете: «Я сподіваюся, що це об’єкт, але якщо ні — поверни null, і я впораюся сам». І ось це «впораюся сам» — наш стиль дня.
Уявіть собі JSON як лабіринт, а JsonElement — як кімнату. Щоб перейти в наступну кімнату, вам потрібно: зрозуміти тип кімнати, акуратно відчинити двері й лише потім рухатися далі. У коді це виглядає як ланцюжок кроків «очікуваний тип → безпечне приведення → наступний крок».
Міні‑приклад «поганий» (крихкий) і «хороший» (стійкий) — на одному й тому самому JSON:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
fun main() {
val root = Json.parseToJsonElement("""{"user":{"name":"Ann"}}""")
// ПОГАНО: jsonObject кидає виняток, якщо root не об’єкт
val obj1: JsonObject = root.jsonObject
// ДОБРЕ: безпечно, якщо root раптом виявиться масивом
val obj2: JsonObject = root as? JsonObject ?: return
println("fields=${obj2.size}") // fields=1
}
Сенс простий: поки ми працюємо з нестабільними даними, примусові перетворення й «гарантії на чесному слові» — наші вороги.
JsonObject["key"]: чому результат — JsonElement?
Коли ви робите obj["age"], результат має тип JsonElement?, тобто nullable. Kotlin ніби наперед попереджає вас: «Поле може бути відсутнім — і це нормально». І це справді нормально: відсутність поля — типовий випадок.
Тут важливо розрізняти два сценарії: «поля немає» і «поле є, але дорівнює null». У першому випадку obj["x"] == null. У другому випадку obj["x"] поверне не null, а вузол JsonNull. Якщо переплутати ці сценарії, можна зробити дуже дивні висновки про дані.
Давайте перевіримо це вручну:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
fun main() {
val root = Json.parseToJsonElement("""{"a":null}""") as? JsonObject ?: return
val a = root["a"] // НЕ null, це JsonNull
val b = root["b"] // null, поля немає
println(a is JsonNull) // true
println(b == null) // true
}
Це важлива цеглинка для майбутньої валідації, але вже зараз вона впливає на те, як ми пишемо «безпечне видобування».
3. JsonPrimitive і безпечні перетворення
Чому ми часто беремо рядок, а не «одразу Int»
Новачку природно хотіти: «Якщо поле — число, хочу Int». Але в дереві JSON примітиви влаштовані хитріше: JsonPrimitive — це контейнер «рядок/число/булеве значення», а ви вже вирішуєте, як інтерпретувати вміст.
Так, JsonPrimitive уміє багато, але в навчальному стилі корисно мислити так: спочатку беремо текст (рядок), потім безпечно перетворюємо. Це дисциплінує й допомагає пережити випадки, коли число прийшло рядком: "21" замість 21.
Ключовий момент: небезпечне перетворення (toInt()) кидає виняток, безпечне (toIntOrNull()) — повертає null. Саме це й потрібно для стійкості на зовнішніх даних.
Перетворення String -> Int?, String -> Double?, String -> Boolean?
Щойно ми вміємо отримувати рядок із примітива, можемо додавати безпечні перетворення. І тут є тонкість: булеве значення теж може бути «брудним». Наприклад, приходить "TRUE" або "yes". Валідацію таких речей ми обговорюватимемо пізніше, але навіть на етапі видобування корисно одразу вибрати стратегію.
Сьогодні візьмемо сувору стратегію: вважаємо true/false валідними, усе інше — null. Для цього зручно використовувати toBooleanStrictOrNull() (суворе перетворення, не вгадує „yes“). Якщо у вашій версії Kotlin воно недоступне, можна тимчасово зробити lowercase() і порівняти вручну, але в Kotlin/JVM зазвичай усе працює.
import kotlinx.serialization.json.JsonObject
fun getIntOrNull(obj: JsonObject, key: String): Int? {
val text = getStringOrNull(obj, key) ?: return null
return text.toIntOrNull()
}
fun getDoubleOrNull(obj: JsonObject, key: String): Double? {
val text = getStringOrNull(obj, key) ?: return null
return text.toDoubleOrNull()
}
fun getBooleanOrNull(obj: JsonObject, key: String): Boolean? {
val text = getStringOrNull(obj, key) ?: return null
return text.toBooleanStrictOrNull()
}
Сенс такий: ми відокремили «видобування даних» від «інтерпретації» — і зробили це без винятків.
4. Helper‑функції для безпечного читання
Коли ви пишете «безпечне видобування» вручну, швидко з’являється копіпаста: те саме obj[key], те саме as?, те саме ?: return null. У цей момент логічно винести це в маленькі helper‑функції.
Важливо: helper‑функції мають бути нудними. Без «магії», без прихованих винятків, без спроб «вгадати, що мав на увазі автор JSON». Їхня сила — у передбачуваності.
Примітиви та рядки
Почнемо з двох простих:
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
fun getPrimitiveOrNull(obj: JsonObject, key: String): JsonPrimitive? {
val el: JsonElement = obj[key] ?: return null
return el as? JsonPrimitive
}
fun getStringOrNull(obj: JsonObject, key: String): String? {
return getPrimitiveOrNull(obj, key)?.content
}
Тут контракт кристально чесний: немає поля або це не примітив — повернули null. Жодних «порожніх рядків за замовчуванням», жодних 0. Ми не приховуємо проблему — ми робимо її керованою.
Вкладені об’єкти: getObjectOrNull
Щойно JSON стає «живим», у ньому з’являються вкладені структури. Наприклад:
{
"expense": {
"id": "10",
"title": "Coffee",
"amount": 250
}
}
Якщо ви щоразу писатимете ((root as? JsonObject)?.get("expense") as? JsonObject), ви дуже швидко почнете ненавидіти JSON як жанр мистецтва. Тож додамо helper для об’єктів:
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
fun getObjectOrNull(obj: JsonObject, key: String): JsonObject? {
val el: JsonElement = obj[key] ?: return null
return el as? JsonObject
}
І використаємо:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
fun main() {
val root = Json.parseToJsonElement("""{"expense":{"amount":"250"}}""")
val rootObj = root as? JsonObject ?: return
val expenseObj = getObjectOrNull(rootObj, "expense") ?: return
val amount = getIntOrNull(expenseObj, "amount")
println(amount) // 250
}
Зверніть увагу: amount у нас прийшов рядком "250", але ми все одно коректно розпарсили його, бо працюємо через .content → toIntOrNull().
Масиви: JsonArray і безпечні індекси
Трохи пізніше (на наступних лекціях) ми збиратимемо звіти й оброблятимемо дані, а там масиви — звична справа: список витрат, список користувачів, список «чого завгодно».
JsonArray поводиться як список (List<JsonElement>). Отже, у нас є хороші інструменти зі стандартної бібліотеки: наприклад, getOrNull(index), щоб не отримати IndexOutOfBoundsException.
Зробімо helper:
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
fun getArrayOrNull(obj: JsonObject, key: String): JsonArray? {
val el: JsonElement = obj[key] ?: return null
return el as? JsonArray
}
І приклад читання першого елемента:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
fun main() {
val root = Json.parseToJsonElement("""{"items":[{"id":1},{"id":2}]}""") as? JsonObject ?: return
val items = getArrayOrNull(root, "items") ?: return
val first = items.getOrNull(0) as? JsonObject
val id = first?.let { getIntOrNull(it, "id") }
println(id) // 1
}
Тут ми не припускаємо, що масив непорожній, і не припускаємо, що перший елемент — об’єкт. Усе перевіряємо.
5. Практичний приклад: імпорт витрати
Міні‑алгоритм безпечного читання
Щоб у голові це не перетворилося на кашу, зручно тримати короткий алгоритм. Він майже завжди однаковий: «дістали вузол → перевірили тип → дістали вміст → перетворили».
Можна уявити це як мініблок-схему:
flowchart TD
A["JsonObject['key']"] --> B{Елемент є?}
B -->|ні| N["null (немає поля)"]
B -->|так| C{Тип вузла підходить?}
C -->|ні| N
C -->|так| D["JsonPrimitive.content"]
D --> E{Конверсія успішна?}
E -->|ні| N
E -->|так| OK["Повернули значення T"]
Головний сенс: ми не «боремося» з помилками через винятки — ми робимо неможливим падіння на етапі видобування.
Парсинг Expense із JsonObject
Зробімо маленький шматочок нашого навчального застосунку. Нехай у нас є сутність витрати, і ми хочемо імпортувати її з JSON‑дерева (без @Serializable, бо формат може бути нестабільним).
Візьмемо просту модель:
data class Expense(
val id: Int,
val title: String,
val amount: Int,
)
Тепер напишемо функцію «спробувати видобути» витрату з JsonObject. Зверніть увагу: це ще не повноцінна валідація бізнес‑правил (наприклад, «amount > 0»), це саме безпечне видобування: «якщо змогли — повернули, якщо ні — null».
import kotlinx.serialization.json.JsonObject
data class Expense(val id: Int, val title: String, val amount: Int)
fun parseExpenseOrNull(obj: JsonObject): Expense? {
val id = getIntOrNull(obj, "id") ?: return null
val title = getStringOrNull(obj, "title") ?: return null
val amount = getIntOrNull(obj, "amount") ?: return null
return Expense(id = id, title = title, amount = amount)
}
Тепер приклад використання з реальним JSON, де типи «танцюють»:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
fun main() {
val text = """
{
"expenses": [
{"id": "1", "title": "Coffee", "amount": 250},
{"id": 2, "title": "Taxi", "amount": "bad-number"}
]
}
""".trimIndent()
val root = Json.parseToJsonElement(text) as? JsonObject ?: return
val arr: JsonArray = getArrayOrNull(root, "expenses") ?: return
for (el in arr) {
val expObj = el as? JsonObject ?: continue
val expense = parseExpenseOrNull(expObj)
println(expense)
// Expense(id=1, title=Coffee, amount=250)
// null
}
}
Ми отримали важливу поведінку: програма не впала на другому елементі, а спокійно вивела null. Так, потім ми це покращимо й повертатимемо Ok/Error з повідомленням (наступна лекція), але на етапі видобування ми вже досягли головного — стійкості.
Чому на етапі видобування краще null, а не «дефолт»
Коли новачок пише getIntOrNull, часто виникає спокуса: «якщо немає поля — поверну 0». На перший погляд це зручно. Але ця зручність токсична: ви втрачаєте інформацію про проблему.
Якщо «немає поля amount» перетворилося на 0, то далі ваш код може вирішити, що витрата на 0 USDT — це реальна витрата. А потім ви розслідуватимете: «Хто додає нульові витрати?», і виявиться, що це ви — тільки в минулому.
Тому базовий стиль дня такий: helper‑функції «видобування» повертають T?. А рішення «що робити, якщо null» ухвалюються вище — або на рівні валідації, або на рівні сценарію (наприклад, пропустити елемент, показати повідомлення користувачу тощо).
6. Типові помилки
Помилка №1: ланцюжки виду obj["x"]!!.jsonPrimitive.content.
Такий код виглядає «гарно» лише до першого нестабільного JSON. !! падає, якщо поля немає, а jsonPrimitive падає, якщо там не примітив. У результаті ви отримуєте падіння під час виконання, хоча могли б отримати null і спокійно обробити ситуацію.
Помилка №2: використовувати toInt() замість toIntOrNull().
toInt() — це «якщо рядок поганий, я падаю». Для зовнішніх даних це зазвичай неправильна стратегія. Приклад із NumberFormatException добре показує різницю: toIntOrNull() — безпечна альтернатива, що повертає null замість винятку.
Помилка №3: не відрізняти «поля немає» від «поле дорівнює null».
Якщо поле відсутнє, obj["k"] поверне null. Якщо поле є, але значення null, ви отримаєте вузол JsonNull. Це різні випадки, і іноді вони означають різні речі (особливо під час імпорту даних і міграцій форматів).
Помилка №4: робити helper‑функції, які «надто розумні».
Якщо getIntOrNull починає приймати "1 000", "1,000", "about 10", "ten", ви раптово перетворюєте видобування на вгадування. Вгадування породжує тихі помилки. Краще тримати helper‑функції суворими й передбачуваними, а «розумні» перетворення робити окремими функціями — і лише там, де це справді потрібно.
Помилка №5: повертати значення за замовчуванням (0, "", false) замість null надто рано.
Значення за замовчуванням приховують проблему й змішують два стани: «значення справді 0» і «значення невідоме/відсутнє». На етапі видобування майже завжди чесніше повернути null, а дефолт підставляти вже на рівні бізнес‑рішення, де ви можете пояснити, чому це коректно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ