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("Invalid JSON: ${e.message}")
}
}
Обратите внимание: мы не делаем здесь никаких проверок полей amount/title. Нам пока важно только одно: «дерево построилось или нет».
Helper‑функции: меньше копипаста, больше смысла
Валидатор почти всегда состоит из повторяющихся шагов: «получи поле», «убедись, что оно примитив», «возьми строковое представление», «сконвертируй». Если писать это руками каждый раз, вы очень быстро начнёте ненавидеть и JSON, и жизнь, и клавиатуру.
Поэтому переиспользуем helper‑функции:
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("Expense must be a JSON object")
val title = getStringOrNull(obj, "title")?.trim()
?: return ValidationResult.Error("Field 'title' is required")
if (title.isBlank()) return ValidationResult.Error("Field 'title' must be non-blank")
val amount = getIntOrNull(obj, "amount")
?: return ValidationResult.Error("Field 'amount' must be an integer")
if (amount <= 0) return ValidationResult.Error("Field 'amount' must be positive")
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("Root must be a JSON array")
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) // Invalid 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: ранняя подмена отсутствующего поля значением по умолчанию, которая маскирует проблему.
Если вы в helper‑функции делаете «нет amount — пусть будет 0», то валидатор может “успешно” пропустить запись, которая на самом деле некорректна. Гораздо безопаснее: helper возвращает null, валидатор решает, ошибка это или допустимое отсутствие, и только валидатор имеет право подставлять дефолт (как мы сделали с category = "other").
Ошибка №4: проверки “бизнес‑правил” до проверки типов и наличия.
Если сначала писать if (amount <= 0) до того, как вы вообще убедились, что amount — это число, вы получите либо кашу из условий, либо исключение. Стабильный порядок обычно такой: структура → наличие → тип → конверсия → ограничения значений. Так сообщения об ошибках будут понятными и повторяемыми.
Ошибка №5: возврат Boolean вместо результата с причиной.
false не объясняет, что исправлять. Сообщение в Error — это не роскошь, а минимальная вежливость по отношению к будущему вам, который через неделю будет разбирать чужой JSON в два часа ночи и шептать: «кто это написал… ах да, я».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ