1. Парсинг і валідація: ролі та контракт результату
Навіть якщо JSON успішно розпарсився, це ще не означає, що дані підходять нашому застосунку. У цій лекції ми побудуємо шар валідації, який перетворює JsonElement на нормальні Kotlin‑дані (або зрозумілу помилку) — і робить це без винятків як «звичайного» механізму керування логікою.
Коли ви починаєте приймати дані «із зовнішнього світу» (файл, користувач, сторонній сервіс), дуже хочеться вірити в краще: «ну там же написано amount, значить буде число». Та реальність зазвичай не така романтична: поле може бути відсутнім, бути null, бути рядком "100", бути рядком "сто" або взагалі називатися інакше. Тому нам потрібна дисципліна: спочатку відповідаємо на запитання «це взагалі JSON?», а потім — «це ті дані, які ми готові обробляти?».
Парсинг vs валідація
Парсинг — це перевірка синтаксису. Тобто «рядок відповідає правилам JSON». Якщо там пропущено кому або лапки, парсер скаже «не можу» (і це нормально). Валідація — це перевірка сенсу та вимог застосунку: поля присутні, типи правильні, значення в адекватних діапазонах, рядки не порожні, числа не відʼємні тощо.
Зручно тримати в голові таку схему (вона ж буде нашою архітектурою функцій):
flowchart LR
A["String (JSON text)"] --> B["parseToJsonElement()"]
B -->|успіх| C["JsonElement (дерево)"]
B -->|помилка| E["Error: 'invalid JSON'"]
C --> D["validate(...)"]
D -->|Ok| F["Нормальні Kotlin-дані"]
D -->|Error| G["Зрозуміла помилка для користувача/лога"]
Головна користь цього розділення — у передбачуваності: код стає прозорішим. У нас є «точка», де JSON перетворюється на дерево, і окрема «точка», де дерево перетворюється на коректну модель. Якщо щось не так, ми точно розуміємо на якому етапі і чому.
Чому Boolean недостатньо, і навіщо Ok/Error
Коли новачок пише «валідацію», часто виходить функція виду fun isValid(x): Boolean. Це непоганий початок, але в реальності він швидко дає збій: якщо валідація повернула false, нам треба зрозуміти чому. Інакше застосунок перетворюється на ворожку: «дані неправильні… які саме? де? що виправити?».
Тому ми задаємо валідатору «дорослий» контракт: він повертає або успіх, або помилку з повідомленням. На цьому етапі домовимося не збирати «мішок із 17 помилок за раз», а повертати одну причину — так простіше і для коду, і для користувача.
Зробимо універсальний тип результату:
sealed class ValidationResult<out T> {
data class Ok<T>(val value: T) : ValidationResult<T>()
data class Error(val message: String) : ValidationResult<Nothing>()
}
Тут важливі дві речі. По‑перше, Ok зберігає вже готове значення потрібного типу T, щоб подальший код працював із нормальними даними, а не продовжував «намацувати JSON палицею». По‑друге, Error зберігає повідомлення, яке можна вивести користувачеві або в лог (у консольному проєкті часто це майже одне й те саме).
І ще один бонус: sealed class чудово дружить з when — компілятор змусить нас обробити всі варіанти (тобто ми не забудемо про помилку й не зробимо вигляд, що все добре).
2. Міні‑сюжет: імпорт витрат у CLI‑застосунок
Щоб приклади не були «у вакуумі», продовжимо розвивати наш навчальний консольний застосунок — умовний Expense Tracker (облік витрат). Раніше ми вже могли зберігати витрати й серіалізувати їх у JSON через @Serializable, але сьогодні сценарій інший: уявіть, що нам надіслали JSON невідомого формату (або «майже нашого», але із сюрпризами), і ми хочемо його імпортувати.
Ми будемо валідувати одну сутність витрати з полями:
- title: рядок, не порожній (наприклад, "Coffee")
- amount: ціле число, строго більше нуля (наприклад, 250)
- category: рядок (може бути порожнім/відсутнім — тоді підставимо "other")
Зробимо модель «вже нормалізованих даних», яку отримаємо після валідації:
data class ExpenseDraft(
val title: String,
val amount: Int,
val category: String
)
Чому Draft? Тому що це «чернетка» після валідації входу: ми ще не говоримо про збереження, ID та інше. Ми просто хочемо гарантувати, що дані нормальні й безпечні для подальшої логіки.
3. Конвеєр: парсимо JSON і валідуємо дані
Дуже поширена помилка — зробити одну гігантську функцію, яка і парсить, і валідує, і друкує, і «про всяк випадок» ловить усі винятки. Зазвичай це виглядає як комбайн: важкий, крихкий і страшний. Зробімо охайніше: parseJson(text) відповідає тільки за те, що текст — коректний JSON; validate... відповідає тільки за те, що структура й значення підходять.
Парсимо текст у дерево JsonElement
parseToJsonElement може викинути виняток, якщо текст не є коректним JSON, тому ми чесно перехопимо помилку й повернемо Error:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
fun parseJson(text: String): ValidationResult<JsonElement> {
return try {
ValidationResult.Ok(Json.parseToJsonElement(text))
} catch (e: Exception) {
ValidationResult.Error("Некоректний JSON: ${e.message}")
}
}
Зверніть увагу: ми не робимо тут жодних перевірок полів amount/title. Нам поки важливо тільки одне: «дерево побудувалося чи ні».
Допоміжні функції: менше копіпасту, більше сенсу
Валідатор майже завжди складається з повторюваних кроків: «отримай поле», «переконайся, що воно примітив», «візьми рядкове представлення», «сконвертуй». Якщо писати це вручну щоразу, ви дуже швидко почнете ненавидіти і JSON, і життя, і клавіатуру.
Тому перевикористаємо допоміжні функції:
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
fun getPrimitiveOrNull(obj: JsonObject, key: String): JsonPrimitive? {
val el = obj[key] ?: return null
return el as? JsonPrimitive
}
import kotlinx.serialization.json.JsonObject
fun getStringOrNull(obj: JsonObject, key: String): String? =
getPrimitiveOrNull(obj, key)?.content
import kotlinx.serialization.json.JsonObject
fun getIntOrNull(obj: JsonObject, key: String): Int? {
val text = getStringOrNull(obj, key) ?: return null
return text.toIntOrNull()
}
Тут є своя «філософія»: ці функції не викидають винятків і не підставляють значення за замовчуванням. Вони просто кажуть: «можу дістати коректно — дістав, не можу — повернув null». А вже валідатор вирішить, null тут допустимо чи це помилка.
Валідуємо одну витрату: JsonElement -> Ok(ExpenseDraft) / Error(message)
Тепер найцікавіша частина: перетворюємо фрагмент JSON на нормальні дані. Ми очікуємо обʼєкт (JsonObject), перевіряємо поля, перевіряємо обмеження й повертаємо результат. Важливо дотримуватися порядку перевірок: спочатку структура (обʼєкт чи ні), потім наявність і тип, а вже далі — правила (діапазони, порожні рядки).
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
fun validateExpense(el: JsonElement): ValidationResult<ExpenseDraft> {
val obj = el as? JsonObject ?: return ValidationResult.Error("Витрата має бути JSON-обʼєктом")
val title = getStringOrNull(obj, "title")?.trim()
?: return ValidationResult.Error("Поле 'title' є обовʼязковим")
if (title.isBlank()) return ValidationResult.Error("Поле 'title' не повинно бути порожнім")
val amount = getIntOrNull(obj, "amount")
?: return ValidationResult.Error("Поле 'amount' має бути цілим числом")
if (amount <= 0) return ValidationResult.Error("Поле 'amount' має бути додатним")
val categoryRaw = getStringOrNull(obj, "category")?.trim()
val category = if (categoryRaw.isNullOrBlank()) "other" else categoryRaw
return ValidationResult.Ok(ExpenseDraft(title = title, amount = amount, category = category))
}
Зверніть увагу на стиль: багато ранніх return. Це не «лінощі», а практична техніка guard clauses: щойно виявили проблему — одразу повертаємо помилку й не продовжуємо обчислення в «зламаному» стані.
Ще один момент: ми нормалізуємо дані. Ми робимо trim() для рядків, а порожню або відсутню категорію зводимо до "other". Завдяки цьому далі в коді маємо менше умов і менше шансів «завалити» застосунок через порожній рядок.
Валідуємо список: додаємо індекс і зупиняємося на першій помилці
Імпорт майже завжди приходить списком: масив витрат. І тут зʼявляється додаткове питання: якщо один елемент поганий, що робити з рештою? У межах цієї лекції виберемо стратегію «fail fast, але красиво»: щойно зустріли помилку — зупиняємося й повертаємо Error, але додаємо до повідомлення індекс елемента. Це помітно полегшує життя під час налагодження даних.
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
fun validateExpenses(el: JsonElement): ValidationResult<List<ExpenseDraft>> {
val arr = el as? JsonArray ?: return ValidationResult.Error("Корінь має бути JSON-масивом")
val items = mutableListOf<ExpenseDraft>()
for ((index, itemEl) in arr.withIndex()) {
when (val r = validateExpense(itemEl)) {
is ValidationResult.Ok -> items.add(r.value)
is ValidationResult.Error -> return ValidationResult.Error("Item[$index]: ${r.message}")
}
}
return ValidationResult.Ok(items)
}
Так, ми могли б «збирати всі помилки», але це окрема (і складніша) техніка. Зараз нам важливіше навчитися тримати код простим і передбачуваним: одна помилка — одна зрозуміла відповідь.
Склеюємо конвеєр у main: читабельність важливіша за «все в один рядок»
Коли все готово, хочеться зробити красиво: «в один рядок розпарсив, провалідував, пішов далі». Але для початківців це часто перетворюється на магію, яку складно налагоджувати. Тому зберемо конвеєр явно — через проміжні val. Такий код виглядає трохи довшим, зате його легко читати й легко виправляти.
import kotlinx.serialization.json.JsonElement
fun main() {
val text = """
[
{"title":"Coffee","amount":250,"category":"food"},
{"title":"Book","amount":900,"category":"education"}
]
""".trimIndent()
val parsed: ValidationResult<JsonElement> = parseJson(text)
when (parsed) {
is ValidationResult.Ok -> {
val validated = validateExpenses(parsed.value)
println(validated) // Ok(value=[ExpenseDraft(...), ...]) або Error(...)
}
is ValidationResult.Error -> println(parsed.message) // Некоректний JSON: ...
}
}
Тут добре видно розділення відповідальності: parseJson відповідає за синтаксис, validateExpenses — за вимоги застосунку. І так, println(validated) друкує «технічний» вигляд результату; у реальному CLI ви б виводили більш дружні повідомлення, але це вже питання оформлення UI, а не валідації.
4. require() і check(): де їхнє місце, а де — Ok/Error
Іноді студент цілком справедливо питає: «А навіщо весь цей ValidationResult, якщо є require(condition) і check(condition)?». Питання хороше, тому що Kotlin справді вміє автоматично викидати винятки через передумови: require() кидає IllegalArgumentException, а check() — IllegalStateException. Це зручно й часто робить код коротшим.
Але нюанс у тому, що саме ви перевіряєте. require() і check() — це інструменти для ситуацій, коли програма не повинна продовжувати роботу, бо сталося порушення контракту: наприклад, розробник неправильно викликав функцію або обʼєкт опинився в неможливому стані. Такі перевірки доречні у «внутрішньому» коді, де помилки означають баг у логіці.
А от зовнішні дані (JSON «з вулиці») — це не баг у програмі. Це нормальна життєва ситуація: дані можуть бути зіпсовані, неповні, несумісні за версією. І в таких випадках винятки як «звичайний шлях керування» часто роблять код менш читабельним. Набагато приємніше повернути ValidationResult.Error("..."), показати користувачеві зрозумілий текст і коректно завершити імпорт без падіння всієї програми.
Якщо зовсім коротко: require/check — про помилки розробника, Ok/Error — про помилки вхідних даних.
5. Типові помилки під час валідації JSON‑даних
Помилка №1: змішування парсингу й валідації в одній функції‑«комбайні».
Коли в одній функції одночасно робляться parseToJsonElement, перевірки полів, друк у консоль і запис у колекцію, стає складно зрозуміти, на якому кроці все зламалося. Розділення на два етапи — парсинг і валідацію — робить код прозорим: якщо не парситься, проблема в синтаксисі; якщо парситься, але валідатор лається — проблема в структурі або значеннях.
Помилка №2: використання примусових приведень as і оператора !! під час читання дерева.
Такий код працює рівно до першого сюрпризу в даних, а потім падає в найбільш невідповідний момент. Валідація цінна тим, що вона має бути стійкою: as? плюс зрозуміла Error(...) майже завжди краще, ніж «воно має бути обʼєктом, я так вирішив».
Помилка №3: надто рання підміна відсутнього поля значенням за замовчуванням, яка маскує проблему.
Якщо ви в допоміжній функції робите «нема amount — нехай буде 0», то валідатор може «успішно» пропустити запис, який насправді некоректний. Набагато безпечніше так: допоміжна функція повертає null, валідатор вирішує, помилка це чи допустима відсутність, і лише валідатор має право підставляти значення за замовчуванням (як ми зробили з category = "other").
Помилка №4: перевірка бізнес‑правил до перевірки типів і наявності.
Якщо спочатку писати if (amount <= 0) до того, як ви взагалі переконалися, що amount — це число, ви отримаєте або кашу з умов, або виняток. Стабільний порядок зазвичай такий: структура → наявність → тип → конверсія → обмеження значень. Так повідомлення про помилки будуть зрозумілими й передбачуваними.
Помилка №5: повернення Boolean замість результату з причиною.
false не пояснює, що саме потрібно виправити. Повідомлення в Error — це не розкіш, а мінімальна ввічливість до майбутнього вас, який за тиждень розбиратиме чужий JSON о другій годині ночі й шепотітиме: «хто це написав… ах так, я».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ