JavaRush /Курси /Kotlin SELF /Анотації @SerialName

Анотації @SerialName і @Transient

Kotlin SELF
Рівень 48 , Лекція 1
Відкрита

1. Чому імена та поля в JSON — це майже публічний API

Якщо ви лише почали програмувати, легко думати так: «Ну модель же моя, файл мій — завтра зміню поле, і все». Але щойно ви зберігаєте JSON на диск, надсилаєте його мережею або просто тримаєте між запусками програми, формат перетворюється на контракт. І от тут починаються пригоди: ви перейменували властивість у Kotlin заради краси — а старі файли раптом перестали читатися.

Уявіть, що JSON — це «анкета», яку заповнюють попередні версії вашої програми. Якщо ви завтра змінили в анкеті запитання "name" на "fullName", старі анкети не стануть чарівним чином іншими. Потрібен механізм сумісності. Саме для цього й існують @SerialName і @Transient.

Наша практична мета на цю лекцію — навчитися змінювати Kotlin‑модель (читабельність, стиль, рефакторинг), не ламаючи формат. І навпаки: підлаштовувати формат під зовнішні вимоги, не перетворюючи Kotlin‑код на набір дивних snake_case‑імен.

2. @SerialName: одне імʼя в Kotlin, інше в JSON

Анотація @SerialName("...") — це спосіб сказати серіалізатору: «У коді властивість називається ось так, але в JSON я хочу бачити інше імʼя». Звучить майже нудно, але це якраз той випадок, коли нудьга = надійність. Ви відокремлюєте внутрішню читабельність коду від зовнішнього контракту формату.

Важливо розуміти: @SerialName — це не про «зробити гарніше», а про «зробити стійкіше». Стійкий формат має один приємний бонус: ви можете рефакторити Kotlin‑код і не боятися, що користувачі (або ви самі за місяць) відкриють програму й побачать «не можу прочитати файл, усе пропало».

Міні‑схема того, що відбувається:

flowchart LR
    A[Kotlin-властивість: note] -->|"@SerialName('title')"| B[JSON-поле: 'title']
    C["JSON-поле: 'title'"] -->|decode| D[Kotlin-властивість: note]

Тобто @SerialName — це двостороння домовленість: так поле називається і під час запису, і під час читання.

Практика: перейменували поле й не зламали старі JSON

Уявімо, що ми розвиваємо наш консольний застосунок для обліку витрат BudgetBuddy. Учора в нас уже була модель, яку ми серіалізуємо та зберігаємо в JSON.

Нехай раніше витрата виглядала так:

import kotlinx.serialization.Serializable

@Serializable
data class ExpenseV1(
    val title: String,
    val amount: Int
)

У певний момент ви вирішуєте, що слово title не відображає зміст (це радше «нотатка/опис»). Тож перейменовуєте поле на note:

import kotlinx.serialization.Serializable

@Serializable
data class ExpenseV2(
    val note: String,
    val amount: Int
)

Тепер уявімо старий JSON (він уже лежить на диску):

val oldJson = """{"title":"Coffee","amount":250}"""

Якщо ви спробуєте прочитати його як ExpenseV2, серіалізатор чесно скаже: «Я не знаю, що таке "title", і не бачу обовʼязкового note». Зазвичай це закінчується винятком.

Виправлення якраз робиться через @SerialName:

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ExpenseV2(
    @SerialName("title") val note: String,
    val amount: Int
)

Тепер старий JSON і далі читатиметься, але в Kotlin‑коді у вас буде нормальне імʼя note.

Міні‑перевірка: прочитали старе й записали нове у старому форматі

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Expense(
    @SerialName("title") val note: String,
    val amount: Int
)

fun main() {
    val json = Json { prettyPrint = true }
    val e = json.decodeFromString<Expense>("""{"title":"Coffee","amount":250}""")
    println(e)                       // Expense(note=Coffee, amount=250)
    println(json.encodeToString(e))  // {"title":"Coffee","amount":250}
}

Зверніть увагу на останній вивід: ми і читаємо, і пишемо поле як "title", тому що це наш контракт формату. Водночас у коді ми живемо з note.

@SerialName для сумісності із зовнішнім JSON

Перейменування заради рефакторингу — не єдина причина використовувати @SerialName. Друга поширена причина — вам приходить JSON із зовнішнього світу, де прийнято snake_case, а в Kotlin — camelCase.

Припустімо, зовнішній формат вимагає:

{
  "user_id": 7,
  "full_name": "Ada Lovelace"
}

А в Kotlin ми хочемо нормальну модель:

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class UserDto(
    @SerialName("user_id") val userId: Int,
    @SerialName("full_name") val fullName: String
)

І це рівно той момент, коли @SerialName робить код читабельним і сумісним одночасно. Без нього ви або тримаєте властивості user_id (що погано для Kotlin‑стилю), або пишете ручний розбір JSON (що зарано й зазвичай боляче).

Маленька таблиця: що коли обирати

Ситуація Чого хочеться в Kotlin Що потрібно в JSON Рішення
Рефакторинг: перейменували titlenote note старе імʼя "title" @SerialName("title")
Зовнішній формат у snake_case createdAt, userId "created_at", "user_id" @SerialName("created_at"), @SerialName("user_id")
Ви придумали формат самі, і він ніде не «живе» будь‑які будь‑які можна без @SerialName, але краще заздалегідь домовитися про стиль

3. @Transient: поле потрібне обʼєкту, але не потрібне формату

Якщо @SerialName керує іменем у форматі, то @Transient керує самим фактом присутності властивості у форматі. Тобто властивість існує в Kotlin‑обʼєкті, але не серіалізується й не десеріалізується.

Тут дуже важливий сенс: @Transient — це про межу між «даними» та «зручностями». Наприклад, ви хочете зберігати всередині обʼєкта кеш, налагоджувальну позначку, тимчасове значення для UI або будь‑яке поле, яке не повинно «витікати» у файл. У такому разі @Transient — ваш запобіжник.

Ще одна важлива деталь: для властивості з @Transient майже завжди потрібне значення за замовчуванням. Інакше під час читання JSON обʼєкт буде неможливо створити, адже серіалізатор не братиме значення з JSON. У такому разі коректніше або задати значення за замовчуванням, або переглянути дизайн.

До речі, в екосистемі Kotlin існує кілька варіантів Transient, і можна випадково імпортувати не той. Компілятор та інструменти часто підсвічують такі ситуації як поширену проблему.

Приклад: налагоджувальне поле, яке не повинно потрапляти в збереження

Повернімося до BudgetBuddy. Припустімо, у якийсь момент ви хочете додати у витрату поле debugNote, щоб розуміти, звідки вона взялася: введена вручну, імпортована, відновлена з файла. Це корисно для налагодження, але це точно не те, що повинно жити в JSON‑сховищі вічно.

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

@Serializable
data class Expense(
    val note: String,
    val amount: Int,
    @Transient val debugNote: String = ""
)

Тепер, навіть якщо ви створите обʼєкт із debugNote, у JSON він не потрапить.

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Expense(
    val note: String,
    val amount: Int,
    @Transient val debugNote: String = ""
)

fun main() {
    val json = Json { prettyPrint = true }
    val e = Expense(note = "Coffee", amount = 250, debugNote = "typed by user")
    println(json.encodeToString(e))
    // {
    //     "note": "Coffee",
    //     "amount": 250
    // }
}

І так, якщо ви зараз подумали «а можна просто зробити поле private?» — можна, але це інша історія. private керує доступом із коду, а @Transient керує контрактом формату. Це дві різні площини.

Читання JSON: чому потрібен дефолт і де можна впасти

Коли ви читаєте JSON, серіалізатор створює обʼєкт і має заповнити всі властивості, які входять у серіалізацію. Але @Transient до неї не входить, отже значення береться лише зі значення за замовчуванням.

Якщо значення за замовчуванням немає, обʼєкт «не складається». У таких ситуаціях правильніше падати швидко й зрозуміло. У Kotlin це часто робиться через require, check, error — стандартні функції передумов, які викидають винятки за порушення умов.

Міні‑приклад: поганий transient без дефолту

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

@Serializable
data class Session(
    val token: String,
    @Transient val cachedLabel: String // немає дефолту — проблема
)

З високою ймовірністю ви отримаєте помилку під час спроби decodeFromString<Session>(...).

Ще одна тонкість: що як поле є в JSON

Уявімо, що в старому JSON чомусь було поле "debugNote", а тепер ви зробили його @Transient. Для серіалізатора це поле стає «невідомим». Якщо у вас Json налаштований як Json { ignoreUnknownKeys = false } (за замовчуванням так і є), читання може впасти через зайве поле.

Тобто @Transient — це не «магічно ігноруй поле», а саме «це поле не частина схеми». Ігнорування невідомих полів — окреме налаштування Json, із яким ми вже знайомі з попередніх лекцій.

Охайний стиль: розділяємо зберігання та зручність

Іноді студентам хочеться скласти в одну @Serializable модель узагалі все: і те, що треба зберігати, і те, що зручно для обчислень, і кеш, і якісь тимчасові прапорці. Це працює… до першої серйозної зміни.

Спокійніший стиль — памʼятати, що серіалізовувана модель схожа на «контракт даних», і вона має бути доволі нудною: прості поля, зрозумілі імена, мінімум сюрпризів. А «зручні штуки» або робляться transient‑полями зі значенням за замовчуванням, або обчислюються окремими функціями, або взагалі живуть окремо.

Поки ми не йдемо у велику архітектуру (це буде окрема велика розмова пізніше), нам достатньо простого правила: якщо поле не зобовʼязане переживати збереження/завантаження, то або робіть його @Transient, або не робіть його частиною @Serializable‑моделі.

4. Типові помилки

Помилка № 1: перейменували властивість у Kotlin і забули, що JSON‑файли вже існують.
Найприкріша помилка — та, що проявляється не одразу. Ви змінили title на note, усе прекрасно компілюється, нові файли зберігаються, але старі більше не читаються. Лікується це дисципліною: якщо формат уже «у світі», стабілізуйте імʼя через @SerialName, а Kotlin‑імʼя змінюйте як хочете.

Помилка № 2: використовувати @SerialName як «спосіб зробити гарно», а не як контракт.
Коли @SerialName ставлять «про всяк випадок» на кожне поле, формат перетворюється на набір випадкових рядків, а міграції стають складнішими. Сенс анотації в тому, щоб закріплювати важливі місця: сумісність зі старими файлами, відповідність зовнішньому API, єдиний стиль іменування (наприклад, snake_case).

Помилка № 3: поставити @Transient, але не дати значення за замовчуванням.
@Transient означає, що поле не читатиметься з JSON. Отже, під час decode воно має звідкись узятися — майже завжди зі значення за замовчуванням. Якщо значення за замовчуванням немає, обʼєкт не вийде створити. І це не «прискіпування», а логіка конструктора: без значення поле не ініціалізоване.

Помилка № 4: імпортувати «не той Transient».
У Kotlin/JVM можна зустріти інші Transient‑анотації, і новачки інколи імпортують їх автоматично через IDE. У підсумку поле не виключається із серіалізації або поводиться не так, як очікувалося. Тому корисно очима перевіряти імпорт: вам потрібен саме kotlinx.serialization.Transient. На практиці це настільки часта історія, що навіть інструменти та інспекції окремо на неї сваряться.

Помилка № 5: вважати, що @Transient автоматично «проковтне» поле з вхідного JSON.
Якщо поле є в JSON, а в моделі його немає (або воно @Transient), для серіалізатора це невідомий ключ. І якщо ви не увімкнули ignoreUnknownKeys, читання може впасти. Тобто @Transient — про вилучення зі схеми, а терпимість до сміття/зайвих полів — це налаштування Json.

Помилка № 6: зберігати в серіалізовуваній моделі те, чому не місце у файлі.
Це вже не стільки про компіляцію, скільки про здоровий глузд. Налагоджувальні нотатки, тимчасові прапорці, кеші, похідні поля — усе це краще або обчислювати «на льоту», або позначати @Transient із безпечним значенням за замовчуванням. Інакше ви самі собі створите «історичний архів сміття», який потім доведеться підтримувати заради сумісності.

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