1. enum: значения в JSON и @SerialName
Когда вы пишете программу, вам постоянно нужно представлять «одно из нескольких». Например: статус операции (успех/ошибка), категория расхода (еда/транспорт), тип команды ("add"/"list"/"remove") или состояние заказа. Наивный подход — хранить это строками вроде "ok" или "food". Он работает ровно до того момента, когда вы делаете опечатку ("foood"), и программа радостно продолжает жить, но уже в параллельной вселенной.
enum и sealed class — это способы сказать компилятору: «смотри, вариантов мало, и других быть не может». В Kotlin это ещё особенно приятно, потому что when по enum и sealed class может быть исчерпывающим: если вы забыли обработать вариант, компилятор (в режиме выражения) начнёт ворчать и спасёт вас от бага. Именно это поведение — «варианты конечны» — и хочется сохранить в JSON-контракте тоже.
Как выглядит enum в JSON по умолчанию
enum — это по сути «словарь допустимых значений». В Kotlin enum class ещё и даёт вам удобные штуки вроде name, ordinal, entries, valueOf(...) и так далее. Но нас сегодня интересует главное: как enum превращается в JSON.
По умолчанию kotlinx.serialization сериализует enum как строку с именем константы. То есть FOOD станет "FOOD". Это просто, читаемо и часто достаточно, но у этого есть тонкая проблема: имя константы — это имя в коде, а JSON — это внешний формат. Иногда внешний формат требует другие значения (например, "food" вместо "FOOD"), а иногда вы хотите спокойно переименовать константу в коде, не ломая старые файлы.
Давайте сделаем мини-пример на основе учебного консольного приложения «учёт расходов». Одна модель расхода и категория как enum.
import kotlinx.serialization.Serializable
@Serializable
enum class ExpenseCategory {
FOOD, TRANSPORT, ENTERTAINMENT
}
Теперь сериализация:
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val json = Json { prettyPrint = true }
println(json.encodeToString(ExpenseCategory.FOOD))
// "FOOD"
}
Ключевая мысль: в JSON лежит имя enum-константы. И если вы завтра переименуете ENTERTAINMENT в FUN (потому что захотели короче), то старые JSON-файлы с "ENTERTAINMENT" перестанут читаться как раньше.
Стабильные значения через @SerialName
В этот момент обычно появляется очень человеческое желание: «давайте никогда не переименовывать enum». Это похоже на обещание «я никогда не буду есть сладкое». Формально возможно, психологически сомнительно.
Правильнее отделить «внутреннее имя в коде» от «внешнего имени в формате». Для этого на значениях enum можно ставить @SerialName. И тогда вы можете назвать константу в коде как удобно вам, а в JSON хранить то, о чём договорились по формату.
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class ExpenseCategory {
@SerialName("food")
FOOD,
@SerialName("transport")
TRANSPORT,
@SerialName("entertainment")
ENTERTAINMENT
}
Проверим сериализацию:
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val json = Json { prettyPrint = false }
println(json.encodeToString(ExpenseCategory.TRANSPORT))
// "transport"
}
Теперь JSON стал «человечнее» (или «API-шнее») и, что важнее, стабильнее: вы можете переименовать ENTERTAINMENT в FUN в Kotlin-коде, но оставить @SerialName("entertainment"), и формат не пострадает.
2. sealed class: дискриминатор и @SerialName
Почему для JSON нужен дискриминатор
Если enum — это «одно из N значений», то sealed class — это «одно из N типов». И каждый тип может нести свои данные. Это как коробка-сюрприз, но честная: сюрпризов мало, все известны заранее, и компилятор держит их в списке.
Классический пример: результат операции. Успех несёт полезные данные (например, id созданного расхода), а ошибка несёт сообщение об ошибке.
import kotlinx.serialization.Serializable
@Serializable
sealed class OpResult {
@Serializable
data class Ok(val id: Int) : OpResult()
@Serializable
data class Error(val message: String) : OpResult()
}
И тут возникает вопрос: как это хранить в JSON?
Если записать просто поля, мы потеряем главное: когда мы читаем JSON обратно, как понять — это Ok или Error? В JSON нет встроенного понятия «sealed class variant». Он видит просто объект { ... }.
Поэтому полиморфной сериализации (а sealed — это как раз полиморфизм «в маленьком») нужно специальное служебное поле: дискриминатор типа. Это поле говорит: «вариант вот такой».
Типичный вид:
{ "type": "ok", "id": 10 }
или
{ "type": "error", "message": "Invalid input" }
Дальше при чтении JSON библиотека смотрит на "type" и решает, какой подтип создавать.
classDiscriminator в Json { ... }
Важно, чтобы дискриминатор имел стабильное имя и не конфликтовал с вашими обычными полями. Самый популярный вариант — "type", но в некоторых доменах слово type уже занято бизнес-смыслом («тип товара», «тип операции»). Тогда лучше выбрать другое имя, например "kind" или "resultType".
В kotlinx.serialization имя поля-дискриминатора задаётся настройкой classDiscriminator.
import kotlinx.serialization.json.Json
val json = Json {
prettyPrint = true
classDiscriminator = "type"
}
Небольшая жизненная деталь: настройка должна быть одинаковой там, где вы пишете JSON, и там, где читаете JSON. Если вы сохранили файл с "type", а потом читаете его Json { classDiscriminator = "kind" }, библиотека будет смотреть не туда и «не узнает» варианты.
@SerialName на вариантах: фиксируем ok/error
Сейчас у нас варианты называются Ok и Error. Если сериализовать их «как есть», внешний формат может начать зависеть от имён классов и пакетов (а пакеты вы вполне можете рефакторить). А ещё в JSON хочется видеть короткие понятные маркеры: "ok" и "error".
Здесь снова помогает @SerialName, но теперь мы ставим его не на поле и не на enum-значение, а на подтип sealed-класса.
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed class OpResult {
@Serializable
@SerialName("ok")
data class Ok(val id: Int) : OpResult()
@Serializable
@SerialName("error")
data class Error(val message: String) : OpResult()
}
Теперь мы явно фиксируем конвенцию Ok/Error как контракт формата, а не «как случайно назвали классы».
Проверим сериализацию:
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val json = Json {
prettyPrint = true
classDiscriminator = "type"
}
val r: OpResult = OpResult.Ok(id = 10)
println(json.encodeToString(r))
/*
{
"type": "ok",
"id": 10
}
*/
}
Обратите внимание на маленькую, но важную вещь: переменная r имеет тип OpResult, а не OpResult.Ok. И это не просто «красиво», это влияет на JSON.
Нюанс: дискриминатор появляется при сериализации через базовый тип
Это тот момент, где студенты чаще всего ловят «а почему оно не работает?» и «почему JSON разный?». Причина — статический тип выражения в Kotlin.
Если вы сериализуете объект как конкретный тип OpResult.Ok, то библиотека считает, что «тип и так известен», и дискриминатор может не понадобиться. А если вы сериализуете как OpResult, то нужен дискриминатор, потому что вариантов несколько.
Сравним:
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val json = Json { classDiscriminator = "type" }
val asChild = OpResult.Ok(id = 10)
val asBase: OpResult = OpResult.Ok(id = 10)
println(json.encodeToString(asChild)) // {"id":10}
println(json.encodeToString(asBase)) // {"type":"ok","id":10}
}
Технически оба JSON валидные, но второй восстанавливается однозначно, потому что в нём есть "type". Первый восстановить как OpResult без дополнительной информации нельзя: { "id": 10 } может быть чем угодно (и тем более — может стать чем угодно после расширения модели).
Практическое правило: если вы проектируете JSON-формат для sealed class и хотите надёжный decodeFromString<OpResult>(...), сериализуйте результат через базовый тип (или явно указывайте тип-параметр).
3. Декодирование и обработка через when
Теперь сделаем обратную операцию: из JSON обратно в Kotlin-объект. Здесь вы почувствуете, зачем нам дискриминатор: именно он позволяет библиотеке выбрать вариант.
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun main() {
val json = Json { classDiscriminator = "type" }
val s1 = """{"type":"ok","id":10}"""
val s2 = """{"type":"error","message":"Bad input"}"""
val r1 = json.decodeFromString<OpResult>(s1)
val r2 = json.decodeFromString<OpResult>(s2)
println(r1) // Ok(id=10)
println(r2) // Error(message=Bad input)
}
Дальше — классика Kotlin: обрабатываем результат исчерпывающим when. На sealed class это особенно приятно, потому что Kotlin знает все варианты и может требовать обработать их все.
fun toHumanText(r: OpResult): String = when (r) {
is OpResult.Ok -> "Успех: создан id=${r.id}"
is OpResult.Error -> "Ошибка: ${r.message}"
}
Этот when хорош тем, что вы не используете null, не используете Boolean-флаги типа isOk, и не делаете «волшебные» строки. У вас есть строгая модель результата, и компилятор помогает вам не забыть обработать вариант.
4. Мини-схема encode/decode для sealed
Чтобы в голове уложилось «почему там появляется type», полезно посмотреть на процесс как на маленький конвейер.
flowchart TD
A["Kotlin: OpResult (sealed)"] -->|encodeToString| B["JSON объект"]
B --> C["поле-дискриминатор: type"]
C --> D["значение: ok / error"]
B -->|decodeFromString⟨OpResult⟩| E["Kotlin: OpResult.Ok или OpResult.Error"]
E -->|when| F["человекочитаемый текст / дальнейшая логика"]
Если на этапе JSON у вас нет type, то путь «назад» в OpResult становится либо невозможен, либо превращается в гадание по внутренностям объекта (а это обычно очень хрупко).
5. Пример в учёте расходов: результат команд как Ok/Error
Сейчас сделаем шаг, который кажется маленьким, но на практике делает программу взрослее: перестанем возвращать «строчки с ошибками» и начнём возвращать строгий результат Ok/Error. А потом (если захотим) сможем этот результат сериализовать в JSON — например, для логов, для экспорта, для тестов.
Добавим минимальные модели приложения:
import kotlinx.serialization.Serializable
@Serializable
data class Expense(
val id: Int,
val title: String,
val amount: Int,
val category: ExpenseCategory
)
И сервис, который добавляет расход и возвращает OpResult. Тут намеренно простая логика, без будущих архитектурных «слоёв» — мы всё ещё учимся держать код понятным.
class ExpenseService {
private val items = mutableListOf<Expense>()
private var nextId = 1
fun add(title: String, amount: Int, category: ExpenseCategory): OpResult {
if (title.isBlank()) return OpResult.Error("Название не должно быть пустым")
if (amount <= 0) return OpResult.Error("Сумма должна быть > 0")
val e = Expense(id = nextId++, title = title, amount = amount, category = category)
items.add(e)
return OpResult.Ok(id = e.id)
}
}
Заметьте, как приятно читается: функция возвращает не Boolean, не Int с «-1 означает ошибку», и не String? (где null означает успех, а строка — ошибка). Она возвращает понятный доменный результат.
Теперь в main можем сделать демонстрацию и заодно показать сериализацию результата:
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val service = ExpenseService()
val json = Json {
prettyPrint = true
classDiscriminator = "type"
}
val r: OpResult = service.add("Кофе", 250, ExpenseCategory.FOOD)
println(toHumanText(r)) // Успех: создан id=1
println(json.encodeToString(r))
}
Если вы сейчас спросите: «а зачем мне сериализовать OpResult, если я его просто печатаю?», ответ такой: сегодня это учебный пример, а в реальности такие результаты часто уходят либо в файл (журнал), либо в сеть (API), либо в тестовые «golden files». И вот тогда дисциплина Ok/Error и стабильные названия вариантов начинают реально окупаться.
6. Типичные ошибки при сериализации enum и sealed class
Ошибка №1: хранить enum в JSON как имя константы и потом свободно переименовывать её в коде.
Проблема не в том, что переименовывать нельзя, а в том, что вы незаметно меняете внешний формат. Сегодня у вас "FOOD", завтра "MEAL", и старые файлы превращаются в музей ошибок. Лечится просто: фиксируйте внешние значения через @SerialName на enum-значениях, а переименовывайте Kotlin-имена сколько душе угодно (в рамках здравого смысла).
Ошибка №2: ожидать, что sealed class восстановится из JSON без поля типа.
JSON не хранит «информацию о подтипе» сам по себе. Если вы не добавили дискриминатор, то decodeFromString<OpResult>(...) не сможет понять, какой подтип создавать. Дискриминатор — это не «лишняя бюрократия», а минимальная цена за однозначность.
Ошибка №3: выбрать имя дискриминатора, которое конфликтует с бизнес-полем.
Если вы поставили classDiscriminator = "type", а потом в модели добавили поле val type: String «по бизнесу», вы устроите вечеринку конфликтов. Лучше заранее зарезервировать имя для дискриминатора (например, "type" или "kind") и не использовать его в обычных данных, либо выбрать менее конфликтное имя.
Ошибка №4: сериализовать вариант sealed как конкретный подтип и удивляться, что дискриминатора нет.
Если вы сериализуете OpResult.Ok(...) как OpResult.Ok, JSON может быть без "type". А потом вы пытаетесь прочитать это как OpResult — и всё, приехали. Если вы проектируете внешний формат, сериализуйте через базовый тип (или явно указывайте тип параметром encodeToString<OpResult>(...)), чтобы дискриминатор всегда присутствовал.
Ошибка №5: делать «зоопарк» вариантов результата вместо стабильной пары Ok/Error.
Очень хочется завести Success/Failure, потом где-то Ok/Error, потом ещё Fine/Bad. В результате код и формат расползаются. Для учебного проекта и для большинства прикладных операций полезно держать единый шаблон результата: Ok для успешного исхода и Error для провала. Это делает обработку when одинаковой во всех местах и уменьшает количество «особых случаев» в голове.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ