JavaRush /Курси /Kotlin SELF /Структура JSON і звʼязок із Kotlin‑типами

Структура JSON і звʼязок із Kotlin‑типами

Kotlin SELF
Рівень 47 , Лекція 1
Відкрита

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") відповідають іменам властивостей
  • значення мають бути сумісні за типами (idInt, titleString тощо)
  • порядок полів не зобовʼязаний збігатися (важливі імена ключів, а не порядок)

Іноді 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‑форма Коментар
Обʼєкт {}
{"id":1,"name":"Ada"}
data class User(...)
Найзручніший шлях: типізована модель
Обʼєкт {}
{"a":1,"b":2}
Map<String, Int>
Доречно для динамічних ключів
Масив []
[1,2,3]
List<Int>
Порядок важливий, елементи однотипні за домовленістю
Масив []
[{...},{...}]
List<Expense>
Частий сценарій «список сутностей»
Рядок
"hello"
String
Лапки обовʼязкові
Число
3.5
Double
Дробова частина → зазвичай Double
Число
10
Int / Long
Вибір залежить від діапазону
Boolean
true
Boolean
Лише true/false, без лапок
null
null
T?
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 першим» — це сигнал, що десь змішалися поняття обʼєкта {} і масиву [], де порядок справді важливий.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ