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 однаковою в усіх місцях і зменшує кількість «особливих випадків» у голові.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ