JavaRush /Курсы /Kotlin SELF /Сериализация enum и sealed class: дискриминатор и Ok/Erro...

Сериализация enum и sealed class: дискриминатор и Ok/Error

Kotlin SELF
48 уровень , 2 лекция
Открыта

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 одинаковой во всех местах и уменьшает количество «особых случаев» в голове.

1
Задача
Kotlin SELF, 48 уровень, 2 лекция
Недоступна
Справочник категорий
Справочник категорий
1
Задача
Kotlin SELF, 48 уровень, 2 лекция
Недоступна
Расход с категорией
Расход с категорией
1
Задача
Kotlin SELF, 48 уровень, 2 лекция
Недоступна
Результат операции
Результат операции
1
Задача
Kotlin SELF, 48 уровень, 2 лекция
Недоступна
Сообщение результата
Сообщение результата
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ