1. JSON как язык «коробок»: какие бывают значения
Прежде чем пытаться «прикрутить JSON к приложению», полезно на минуту представить, что JSON — это очень простой язык описания данных, где есть всего несколько видов «коробок». Если вы научитесь быстро определять, какая коробка перед вами (объект, массив, примитив или null), вы резко снизите количество будущих ошибок. И да, это тот редкий случай, когда внимательное чтение скобочек реально экономит часы отладки.
В JSON существует всего четыре формы значений:
- объект { ... }
- массив [ ... ]
- примитив: строка, число, boolean
- 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 первым» — это сигнал, что где-то смешались понятия объекта {} и массива [], где порядок действительно важен.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ