1. JSON як мова «коробок»: які бувають значення
Перш ніж намагатися «прикрутити JSON до застосунку», корисно на мить уявити, що JSON — це дуже проста мова опису даних, у якій є лише кілька видів «коробок». Якщо ви навчитеся швидко визначати, яка коробка перед вами (обʼєкт, масив, примітив або null), ви різко зменшите кількість помилок у майбутньому. І так, це той рідкісний випадок, коли уважне читання дужок справді економить години налагодження.
У JSON є лише чотири форми значень:
- обʼєкт { ... }
- масив [ ... ]
- примітив: рядок, число, булеве значення
- null як окреме значення
Щоб «відчути» це на Kotlin, поки що зберігатимемо JSON як звичайний рядок. І це нормально: JSON, по суті, і є текст.
fun main() {
val obj = """{"id": 1, "name": "Ada"}"""
val arr = """[1, 2, 3]"""
val prim = """"hello""""
val nil = "null"
println(obj) // {"id": 1, "name": "Ada"}
println(arr) // [1, 2, 3]
println(prim) // "hello"
println(nil) // null
}
Зверніть увагу на «подвійні лапки всередині рядка». У Kotlin для JSON дуже зручно використовувати багаторядкові рядки """...""": у них майже не потрібно екранувати лапки.
2. JSON‑обʼєкт: ключі та значення і їхній звʼязок із Kotlin
JSON‑обʼєкт зазвичай виглядає як набір полів, «схожий на словник». Саме тому часто кажуть: «JSON — це як обʼєкт». І це справді схоже на обʼєкт… доки ви не згадаєте, що JSON не знає ні класів, ні методів, ні приватних полів — лише дані. Тож, зіставляючи JSON‑обʼєкт із Kotlin‑типом, зазвичай думають або про data class, або про Map<String, …>.
У JSON‑обʼєкті:
- фігурні дужки { ... } позначають обʼєкт
- усередині йдуть пари "ключ": значення
- ключ завжди рядок і завжди в подвійних лапках
- пари розділяються комами
Приклад JSON‑обʼєкта для однієї витрати в нашому практичному консольному застосунку «Expense Tracker» (умовно — облік витрат):
{
"id": 10,
"title": "Coffee",
"amount": 3.5,
"category": "food"
}
Тепер — головне зіставлення. На боці Kotlin це найчастіше стає моделлю. Ми вже вміємо працювати з data class, тож використаємо його як «чесний контейнер даних»:
data class Expense(
val id: Int,
val title: String,
val amount: Double,
val category: String
)
Чому це схоже?
- ключі JSON ("id", "title") відповідають іменам властивостей
- значення мають бути сумісні за типами (id → Int, title → String тощо)
- порядок полів не зобовʼязаний збігатися (важливі імена ключів, а не порядок)
Іноді JSON‑обʼєкт зручно уявляти як Map<String, Any?>, адже це буквально «ключ‑значення». Але на практиці це менш типобезпечно: замість зрозумілого expense.amount: Double ви отримуєте «щось» типу Any? — і далі доводиться розбиратися вручну. Проте як ідея зіставлення це важливо: JSON‑обʼєкт за змістом найближчий і до Map, і до «обʼєкта з полями». Сам Map як тип «сховища пар ключ‑значення» ви вже зустрічали в Kotlin.
Невелика візуальна схема:
flowchart LR
A["JSON обʼєкт { ... }"] --> B["Kotlin data class (типізована модель)"]
A["JSON обʼєкт { ... }"] --> C["Kotlin Map⟨tring, Any?⟩ (динамічна форма)"]
Ще одна мініперевірка для читання «очима»: якщо ви бачите { на початку (після пробілів), то верхній рівень — обʼєкт.
fun main() {
val text = """ { "id": 1 } """
val first = text.trim().first()
println(first) // {
}
3. JSON‑масив: порядок і звʼязок із Kotlin List
Після обʼєктів другий за популярністю звір — JSON‑масив. Він схожий на список значень. Тут легко «спіймати» інтуїцію: квадратні дужки — це «коробка з елементами в певному порядку». І, на відміну від обʼєкта, у масиві немає ключів: є лише значення, і важливий саме їхній порядок.
JSON‑масив:
- починається з [ і закінчується ]
- містить значення, розділені комами
- значення можуть бути будь‑якими (обʼєкти, масиви, примітиви, null)
- порядок елементів важливий
Приклад: список витрат (верхній рівень — масив обʼєктів):
[
{ "id": 1, "title": "Coffee", "amount": 3.5, "category": "food" },
{ "id": 2, "title": "Bus", "amount": 2.0, "category": "transport" }
]
У Kotlin найприродніше зіставлення — List<Expense> або MutableList<Expense>. Списки в Kotlin — базовий інструмент для «наборів елементів», у якому порядок зберігається.
Поки ми не парсимо JSON, але вже можемо тримати «ідеальну ціль» у голові: хочемо прийти до списку обʼєктів Expense.
data class Expense(
val id: Int,
val title: String,
val amount: Double,
val category: String
)
fun main() {
val expenses = listOf(
Expense(1, "Coffee", 3.5, "food"),
Expense(2, "Bus", 2.0, "transport"),
)
println(expenses.size) // 2
println(expenses[0].title) // Coffee
}
Зіставлення «JSON‑масив → Kotlin‑список» — це майже завжди шлях номер один, коли ви зберігаєте «багато однотипних сутностей». Є лише тонкий момент: JSON формально не забороняє змішувати типи в масиві (наприклад [1, "two", null]), але в застосунках це майже завжди погана ідея. Зазвичай потрібен масив «одного типу за контрактом»: або список витрат, або список рядків‑тегів, або список чисел.
4. Примітиви JSON: рядки, числа, boolean
Коли ви читаєте JSON, примітиви — це те, що не містить усередині інших значень. Вони здаються простими… доки не починаються питання на кшталт: «це число точно Int, чи там може бути дробова частина?» або «це рядок "1" чи число 1?». Тож краще заздалегідь домовитися із собою: примітиви — це місце, де контракт даних особливо важливий.
У JSON є три «звичайні» примітиви:
- рядок: "text"
- число: 123, 3.14, -10
- boolean: true або false
Окремим пунктом стоїть null, але про нього — у наступному розділі.
Рядок JSON ↔ String у Kotlin
Рядок завжди в подвійних лапках:
fun main() {
val jsonTitle = """"Coffee""""
println(jsonTitle) // "Coffee"
}
Якщо ви бачите лапки — це рядок. Навіть якщо всередині цифри.
Boolean JSON ↔ Boolean у Kotlin
Boolean пишеться без лапок:
fun main() {
val jsonActive = "true"
println(jsonActive) // true
}
Числа JSON ↔ Int/Long/Double у Kotlin
А тут починається реальність. У JSON «число» — одне. Там немає окремого Int, Long, Double. Є просто «number». Тож зіставлення — це рішення, яке диктує контракт:
- якщо поле за змістом ціле (id, count) — у Kotlin це зазвичай Int (або Long, якщо очікуються великі значення)
- якщо поле може бути дробовим (amount, price) — зазвичай Double
Для нашого трекера витрат логічно:
- id: Int
- amount: Double
І хороший «очний тест» такий: побачили крапку — майже напевно потрібен Double.
fun main() {
val amountJson = "3.5"
val idJson = "10"
println(amountJson) // 3.5
println(idJson) // 10
}
Ще один важливий момент: якщо ви випадково запишете число як рядок, то отримаєте формально валідний JSON, але зламаєте типовий контракт:
{ "amount": "3.5" } // рядок, а не число
{ "amount": 3.5 } // число
Обидва варіанти JSON валідні, але для вашої моделі це різні речі.
5. null у JSON і Kotlin: T? та відсутність поля
Тема null зазвичай виглядає як «та що там складного», а потім перетворюється на кілька вечорів роздумів: чому «ніби все однаково, але поведінка інша». Тож розберімося спокійно. JSON уміє зберігати null як окреме значення, а Kotlin уміє зберігати null лише в nullable‑типах (T?). Звучить просто, але є ще один шар: у JSON поле може бути взагалі відсутнім.
Ось три різні ситуації:
{ "nickname": null } // поле є і явно null
{ "nickname": "Ada" } // поле є і рядок
{ } // поля nickname немає взагалі
У Kotlin найпряміше зіставлення для «може бути рядок або може не бути значення» — це String?.
data class UserProfile(
val name: String,
val nickname: String?
)
Але важливо: nickname = null і «ключа "nickname" не було» — це різні варіанти даних. Сьогодні ми не обговорюємо, як конкретна бібліотека серіалізації трактуватиме ці випадки (це буде пізніше, коли зʼявиться кодек і налаштування). Проте як дизайнер контракту ви маєте розуміти різницю:
- nickname: null часто означає «значення відоме як порожнє / видалене / не задане»
- відсутність поля інколи означає «старий формат даних», «поле не підтримується», «ми його не надсилаємо»
Для читання «очима» це означає просте правило: якщо ви бачите null без лапок — це не рядок, а саме null. Якщо ж бачите "null" — це рядок із чотирьох літер n-u-l-l. Так, він поводитиметься як рядок (і саме так інколи випадково «ламають» дані).
6. Вкладеність JSON: як це перетворюється на вкладені типи Kotlin
Коли JSON стає «дорослим», він майже завжди стає вкладеним. Лякатися цього не потрібно: вкладеність — це просто рекурсивне застосування тих самих правил. Обʼєкт може містити масиви, масив може містити обʼєкти тощо. Якщо ви впевнено читаєте верхній рівень і вмієте спускатися всередину, ви вже на 80% готові до реального життя.
Зробімо в нашому Expense Tracker модель трохи багатшою: нехай категорія буде не рядком, а обʼєктом із кодом і назвою. Це потрібно лише для тренування читання вкладеності.
JSON однієї витрати стане таким:
{
"id": 1,
"title": "Coffee",
"amount": 3.5,
"category": { "code": "food", "title": "Food" }
}
А Kotlin‑модель стане вкладеною:
data class Category(
val code: String,
val title: String
)
data class Expense(
val id: Int,
val title: String,
val amount: Double,
val category: Category
)
Тепер читаємо JSON очима й буквально «проєктуємо» його в типи:
- верхній рівень { ... } → один Expense
- поле "category": { ... } → усередині лежить обʼєкт → отже тип поля Category, а не String
- усередині category є "code" і "title" → два рядкових поля
Якщо тепер у нас список витрат, то це буде масив обʼєктів, кожен із яких містить вкладений обʼєкт:
[
{
"id": 1,
"title": "Coffee",
"amount": 3.5,
"category": { "code": "food", "title": "Food" }
},
{
"id": 2,
"title": "Bus",
"amount": 2.0,
"category": { "code": "transport", "title": "Transport" }
}
]
У Kotlin ціль зрозуміла: List<Expense>, де всередині Expense лежить Category.
І ось тут часто трапляється «приємне прозріння»: вкладеність JSON майже один в один збігається з вкладеністю data class. Ви буквально отримуєте «дерево даних», і Kotlin‑типи теж стають деревом.
7. Як читати JSON очима перед тим, як писати код
Коли ви вперше отримуєте JSON (із файла, від сервера, від колеги, з логів — байдуже), хочеться одразу писати код. Але значно дешевше спочатку зробити маленьку паузу й відповісти на кілька запитань: що на верхньому рівні, які ключі, де масиви, де nullable‑поля. Це як прочитати рецепт перед тим, як увімкнути духовку. І так, програмісти теж уміють готувати — просто в нас духовка називається «продакшн».
Дуже практичний мініалгоритм такий:
flowchart TD
A["JSON як текст"] --> B["trim()"]
B --> C{"Перший символ"}
C -->|"{" | D["Це обʼєкт -> очікуємо data class / Map"]
C -->|"[" | E["Це масив -> очікуємо List<...>"]
C -->|"" | F["Це рядок"]
C -->|t/f| G["Це boolean"]
C -->|цифра/мінус| H["Це число -> вирішуємо Int/Double"]
C -->|n| I["Це null"]
Реалізуймо крихітну допоміжну функцію в нашому консольному проєкті, яка хоча б підкаже «що зверху». Це не парсер — лише діагностика рівня «подивитися на перший символ».
fun detectTopLevelKind(jsonText: String): String {
val first = jsonText.trim().firstOrNull() ?: return "empty"
return when (first) {
'{' -> "object"
'[' -> "array"
'"' -> "string"
't', 'f' -> "boolean"
'n' -> "null"
'-', in '0'..'9' -> "number"
else -> "unknown"
}
}
fun main() {
println(detectTopLevelKind(""" {"id": 1} """)) // object
println(detectTopLevelKind(""" [1, 2, 3] """)) // array
println(detectTopLevelKind("null")) // null
println(detectTopLevelKind(" 12.5 ")) // number
}
Шпаргалка: JSON → Kotlin
Коли мозок утомився від фігурних дужок, таблиця рятує. Це не «закон всесвіту», а практична шпаргалка: які структури найчастіше обирають у Kotlin під відповідні шматки JSON. Тут важливо памʼятати: JSON сам по собі не знає, що таке Int або Double. Тому деякі зіставлення залежать від контракту даних.
| JSON‑форма | Приклад | Типова Kotlin‑форма | Коментар |
|---|---|---|---|
| Обʼєкт {} | |
|
Найзручніший шлях: типізована модель |
| Обʼєкт {} | |
|
Доречно для динамічних ключів |
| Масив [] | |
|
Порядок важливий, елементи однотипні за домовленістю |
| Масив [] | |
|
Частий сценарій «список сутностей» |
| Рядок | |
|
Лапки обовʼязкові |
| Число | |
|
Дробова частина → зазвичай Double |
| Число | |
|
Вибір залежить від діапазону |
| Boolean | |
|
Лише true/false, без лапок |
| null | |
|
Nullable‑тип, інакше Kotlin не дозволить |
8. Типові помилки під час роботи зі структурою JSON
Помилка № 1: ключі без лапок.
Дуже часта історія: людина пише щось на кшталт { id: 1 }, бо це схоже на JavaScript‑обʼєкт у коді. Але JSON суворіший: ключ має бути рядком у подвійних лапках — "id": 1. Якщо забути лапки, JSON перестає бути JSON, і парсер чесно скаже: «я таке не їм».
Помилка № 2: плутанина числа й рядка.
"1" і 1 виглядають майже однаково, але для контракту це різні типи. Якщо ви в поле amount випадково поклали "3.5" рядком, то модель, яка очікує число, «зламається» під час читання. Це особливо підступно, коли дані формує людина вручну (у конфігурації або в тестовому файлі).
Помилка № 3: null і "null" — це не одне й те саме.
null — спеціальне значення «немає значення». "null" — звичайний рядок із чотирьох символів. Якщо переплутати, ви можете отримати ситуацію, коли замість відсутнього значення у вас раптом текст "null", і далі в коді починаються дивні перевірки типу if (x == "null") (а це вже майже ознака того, що застосунок намагається поплисти в море милиць).
Помилка № 4: зайва кома в кінці обʼєкта або масиву.
У деяких мовах і форматах «кома в кінці» допустима. У JSON — ні. Тобто [1,2,3,] і {"a":1,} — невалідні. Саме тому люди швидко доходять думки: «не хочу руками збирати JSON рядками». І це абсолютно здорова думка.
Помилка № 5: переплутали верхній рівень — обʼєкт vs масив.
Якщо зверху {}, то ви очікуєте «один обʼєкт» (наприклад, Expense). Якщо зверху [], то ви очікуєте «список обʼєктів» (наприклад, List<Expense>). Спроба прочитати масив як обʼєкт (або навпаки) — одна з найчастіших причин помилок на старті, бо «очима» це інколи пропускають, особливо коли JSON довгий і багаторядковий.
Помилка № 6: зробили модель Kotlin, але забули, що вкладеність має збігатися.
Якщо в JSON поле "category" — обʼєкт {...}, а в Kotlin ви залишили category: String, то навіть за ідеальних даних зіставлення «за структурою» не вийде. Вкладені {} майже завжди означають вкладений тип (data class Category(…)) або, у більш динамічному підході, вкладений Map. Але триматися за рядок там, де лежить обʼєкт, — шлях до вічного болю.
Помилка № 7: очікують, що порядок полів в обʼєкті важливий.
У JSON‑обʼєкті важливі ключі, а не порядок. Тому { "id": 1, "name": "Ada" } і { "name": "Ada", "id": 1 } за змістом еквівалентні. Якщо ви ловите себе на думці «а раптом парсер читає тільки якщо id першим» — це сигнал, що десь змішалися поняття обʼєкта {} і масиву [], де порядок справді важливий.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ