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». Він бачить просто обʼєкт { ... }.

Тому поліморфній серіалізації (а sealed — це якраз поліморфізм «у мініатюрі») потрібне спеціальне службове поле — дискримінатор типу. Воно каже: «варіант ось такий».

Типовий вигляд:

{ "type": "ok", "id": 10 }

або

{ "type": "error", "message": "Некоректне введення" }

Далі під час читання JSON бібліотека дивиться на "type" і вирішує, який підтип створювати.

classDiscriminator у Json { ... }

Важливо, щоб дискримінатор мав стабільне імʼя й не конфліктував зі звичайними полями. Найпопулярніший варіант — "type". Але в деяких доменах слово type уже зайняте бізнес-сенсом («тип товару», «тип операції»). Тоді краще вибрати інше імʼя, наприклад "kind" або "resultType".

У kotlinx.serialization імʼя поля-дискримінатора задається налаштуванням classDiscriminator.

import kotlinx.serialization.json.Json

val json = Json {
    prettyPrint = true
    classDiscriminator = "type"
}

Невелика, але життєва деталь: налаштування має бути однаковим там, де ви пишете 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":"Некоректне введення"}"""

    val r1 = json.decodeFromString<OpResult>(s1)
    val r2 = json.decodeFromString<OpResult>(s2)

    println(r1) // Ok(id=10)
    println(r2) // Error(message=Некоректне введення)
}

Далі — класика 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 однаковою в усіх місцях і зменшує кількість «особливих випадків» у голові.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ