1. Вступ
Коли ви пишете невелику програму, цілком нормально, що всюди трапляються String, Int, Long. Але щойно зʼявляється хоча б один мініпроєкт (наприклад, наш консольний облік витрат), відкривається цікава реальність: один і той самий тип даних може означати зовсім різні сутності.
Наприклад, Int може бути і «ID витрати», і «кількістю днів», і «відсотком знижки». А String — і «назвою категорії», і «email», і «командою в CLI». Компілятору байдуже: він бачить просто Int і просто String. І от тут починається класика: «ой, я передав не те, але воно скомпілювалося».
Наша мета сьогодні — дати словам у коді нормальні імена. Іноді — просто заради читабельності. А іноді — так, щоб компілятор став нашим суворим другом і не пропускав нісенітниць.
2. typealias: гарне імʼя без нового типу
Коли ви читаєте сигнатуру функції на кшталт fun addExpense(id: Int, amount: Long, category: String), мозок робить паузу: «А Int — це що? Скільки? ID? Вік кота?». І це не ваша вина: так улаштовані базові типи — вони не зберігають сенсу.
typealias розвʼязує рівно задачу читабельності. Він дозволяє оголосити псевдонім для наявного типу: по суті, ви кажете «нехай String у цьому місці називатиметься CategoryName». Але важливо: це не новий тип, а лише інше імʼя.
Мініприклад typealias на нашому проєкті
Уявімо, що в нашому застосунку «Облік витрат» витрата має:
- id (поки Int)
- category (поки String)
Зробімо код трохи більш «людським»:
typealias ExpenseId = Int
typealias CategoryName = String
data class Expense(
val id: ExpenseId,
val title: String,
val category: CategoryName,
val amountCents: Long,
)
Тепер сигнатури функцій стають приємнішими:
typealias ExpenseId = Int
fun removeExpenseById(expenses: MutableList<Expense>, id: ExpenseId): Boolean {
val index = expenses.indexOfFirst { it.id == id }
if (index == -1) return false
expenses.removeAt(index)
return true
}
Виглядає охайніше: одразу зрозуміло, що параметр — це саме ідентифікатор витрати.
Головний підступ typealias
Критично важливий факт: typealias сумісний за присвоюванням із початковим типом і з іншими аліасами того самого початкового типу. Kotlin прямо підкреслює різницю: typealias — це просто альтернативне імʼя наявного типу, а не новий тип.
Тобто ось такий код… скомпілюється:
typealias UserId = String
typealias ProductId = String
fun printUser(id: UserId) {
println("користувач=$id")
}
fun main() {
val product: ProductId = "P-10"
printUser(product) // компілюється, бо обидва — String
}
З погляду Kotlin це «ну так, String є String». А з погляду сенсу — ми щойно нагодували функцію «ID користувача» ідентифікатором продукту. Це як підписати посилку «кава», а всередину покласти шурупи: формально коробка є, але очікування постраждали.
Коли typealias доречний
Після цього підступу легко захотіти сказати: «Тоді навіщо він узагалі потрібен?». Потрібен — і часто дуже.
typealias чудово працює, коли:
- вам потрібно зробити сигнатури функцій читабельними (особливо в утилітах/сервісах);
- «переплутати значення» або малоймовірно, або не критично;
- ви не хочете ускладнювати код обгортками й .value.
Наприклад, для «назви категорії» (CategoryName) помилка «переплутали з іншим String» можлива, але часто її все одно доводиться відловлювати логікою (порожній рядок, пробіли, нормалізація регістру). Там типобезпечність не завжди окупається.
Ще один частий сценарій — довгі функціональні типи (ми їх проходили раніше), де typealias перетворює «локшину зі стрілочок» на нормальне слово. Але в межах сьогоднішньої лекції тримаймося ближче до предметних значень.
3. value class: окремий тип поверх одного значення
Тепер — до «важкої артилерії». Якщо typealias — це «перейменувати табличку на дверях», то value class — «збудувати окрему кімнату». На вигляд схоже: і там, і там ви хочете «позначити сенс». Але результат принципово інший.
value class створює справжній новий тип, який компілятор відрізняє від початкового типу й від інших value-класів поверх того самого початкового типу. Саме це дає нам захист від випадкових підстановок.
Kotlin описує такі класи як inline value classes: вони зберігають одне значення, і це значення «вбудовується» у використання (без окремого «товстого обʼєкта» в більшості випадків).
На Kotlin/JVM (а ми зараз саме на JVM) value class зазвичай пишуть з анотацією @JvmInline.
Приклад: ExpenseId як value class
Зробімо ідентифікатор витрати справжнім окремим типом:
@JvmInline
value class ExpenseId(val value: Int)
І тепер модель:
data class Expense(
val id: ExpenseId,
val title: String,
val category: String,
val amountCents: Long,
)
Що це дає на практиці
Тепер компілятор не дасть переплутати ExpenseId і «просто число»:
@JvmInline
value class ExpenseId(val value: Int)
fun removeExpenseById(expenses: MutableList<Expense>, id: ExpenseId): Boolean = true
fun main() {
val id = ExpenseId(10)
// removeExpenseById(mutableListOf(), 10) // не компілюється
removeExpenseById(mutableListOf(), id) // гаразд
}
Тобто «помилка за сенсом» перетворюється на «помилку компіляції». Це ідеальний результат: ви ловите проблему ще до запуску програми, ще до тестів, ще до того, як користувач напише вам «усе пропало».
4. Обмеження й правила value class
У value-класів є обмеження. Вони зʼявилися не тому, що Kotlin вередує, а тому що така конструкція має залишатися «легкою» й передбачуваною.
Рівно одне поле в primary constructor
Inline value class має містити одну-єдину властивість, ініціалізовану в primary constructor.
Тобто так — можна:
@JvmInline
value class Money(val cents: Long)
А так — не можна:
@JvmInline
value class Money(val cents: Long, val currency: String) // помилка: два поля
Якщо вам потрібно два поля — це вже кандидат на data class, а не на value class.
Можна мати init і перевіряти інваріанти
Дуже приємний плюс: value-клас може мати init, і ви можете одразу захистити сенс типу. А самі перевірки зручно робити через require(...), який ми вже використовували в конструкторах і init у звичайних класах.
Наприклад, «гроші не повинні бути відʼємними»:
@JvmInline
value class Money(val cents: Long) {
init {
require(cents >= 0) { "Money must be non-negative: $cents" }
}
}
Це перетворює «домовленість у голові» на «правило в коді». І це чудово: мозок можна витрачати на задачі, а не на втримування сотні домовленостей.
Чому === для value class не має сенсу
Inline value classes можуть бути представлені як «чисте значення» або як «обгортка» в деяких випадках. Тому посилальна рівність (===) для них заборонена як беззмістовна.
Практичний висновок простий: порівнюйте value class як значення — через ==.
@JvmInline
value class ExpenseId(val value: Int)
fun main() {
val a = ExpenseId(1)
val b = ExpenseId(1)
println(a == b) // true
}
5. Вбудовуємо value class у проєкт «Облік витрат»
Зараз зробимо дуже практичний крок: підсилимо модель так, щоб вона рідше допускала беззмістовні стани.
Робимо типи домену: ExpenseId і Money
@JvmInline
value class ExpenseId(val value: Int)
@JvmInline
value class Money(val cents: Long) {
init {
require(cents >= 0) { "Money must be non-negative: $cents" }
}
}
Оновлюємо модель Expense
Зверніть увагу: гроші робимо типом Money, а категорію поки залишимо рядком (або typealias). Категорія — це радше «текстова мітка», а от гроші й ID особливо легко переплутати.
typealias CategoryName = String
data class Expense(
val id: ExpenseId,
val title: String,
val category: CategoryName,
val amount: Money,
)
Додаємо невеликий генератор ID
Поки без «складних сховищ» і без тем із майбутніх лекцій — просто акуратно створимо наступний ID на основі поточних витрат.
fun nextExpenseId(expenses: List<Expense>): ExpenseId {
val max = expenses.maxOfOrNull { it.id.value } ?: 0
return ExpenseId(max + 1)
}
Так, це неідеально для паралельності, баз даних і реального світу. Але для навчального консольного застосунку — чудово. І головне: видно, де саме ми переходимо до «сирого числа» (.value).
Функція додавання витрати
fun addExpense(
expenses: MutableList<Expense>,
title: String,
category: CategoryName,
amountCents: Long,
) {
val expense = Expense(
id = nextExpenseId(expenses),
title = title,
category = category,
amount = Money(amountCents),
)
expenses.add(expense)
}
Зверніть увагу на приємний ефект: конструктор Money(amountCents) сам валідує вхід. Якщо ви спробуєте передати -10, програма впаде одразу й чесно, а не створить «витрату мінус 10 центів» (яка потім ламає звіти й викликає філософське питання: «мені доплатили за купівлю хліба?»).
Друк витрати
fun printExpense(e: Expense) {
println("#${e.id.value}: ${e.title}") // #1: Coffee
println("Category: ${e.category}") // Category: food
println("Amount: ${e.amount.cents} cents") // Amount: 250 cents
}
Тут .value / .cents — плата за типобезпечність. Часто це цілком нормально: ви явно бачите межу між «нашим смисловим типом» і «сирим примітивом».
6. typealias vs value class: таблиця вибору
Коли інструменти зовні схожі, мозок новачка починає панікувати: «що вибирати, щоб не було соромно?». Знімімо тривогу простим порівнянням.
| Критерій | typealias | value class |
|---|---|---|
| Створює новий тип? | Ні, це псевдонім наявного типу. | Так, це окремий тип поверх одного значення. |
| Захищає від плутанини між UserId і ProductId? | Ні | Так |
| Вимагає .value / .cents? | Ні | Так (зазвичай так, бо є обгортка) |
| Можна валідувати сенс у init? | Ні (це не клас) | Так, можна init { require(...) } |
| Обмеження «одне поле» | Не застосовується | Так, одне поле в primary constructor |
| Посилальна рівність === | Можна (якщо початковий тип — посилальний), але майже ніколи не потрібно | Беззмістовно й заборонено |
І дуже практичне правило «на щодень»: якщо ви хочете просто читати код без розшифровки — беріть typealias. Якщо ви хочете, щоб компілятор бив вас по руках за плутанину, — беріть value class.
7. Нюанс API: не перетворюємо код на «музей обгорток»
Є спокуса загорнути у value class взагалі все: Title, CategoryName, Command, Email, CityName… І раптом ваш код стає схожим на музей обгорток, де не можна просто передати рядок без трьох ритуалів.
Психологічно ця спокуса зрозуміла: «раз типобезпечно — значить краще». Але на практиці типобезпечність має окуповувати свою «церемоніальність».
У нашому застосунку справді небезпечні:
- ID (їх легко переплутати, а помилку складно помітити),
- гроші/суми (помилка в одиницях або знак «мінус» часто ламає обчислення).
А от «категорія» найчастіше все одно потребує нормалізації (trim, lowercase) і допускає вільне введення користувача. Тому там часто достатньо typealias, а типобезпечність забезпечує не тип, а валідація рядка.
8. Типові помилки
Помилка № 1: очікувати, що typealias захистить від плутанини значень.
typealias робить код читабельнішим, але не створює нового типу: усе лишається сумісним із початковим типом, і компілятор не зупинить вас, якщо ви передасте «не той String». Якщо потрібен саме захист — використовуйте value class.
Помилка № 2: намагатися зробити value class із двома (або нулем) полями.
Inline value class має містити рівно одне поле в primary constructor. Якщо вам потрібно більше даних — це вже не value class, а кандидат на data class.
Помилка № 3: забути, що value class — це новий тип, і дивуватися: «чому не компілюється?»
Після переходу з Int на ExpenseId ви більше не зможете викликати функції «старими числами». Це не баг, а сенс зміни: компілятор тепер вимагає явного створення ExpenseId(…), щоб ви свідомо сказали: «це саме ID».
Помилка № 4: не валідувати сенс, хоча тип прямо просить про це.
Якщо ви вводите Money, але дозволяєте відʼємні значення, ви втрачаєте половину користі. init { require(...) } — чудовий спосіб вбудувати інваріант прямо в тип.
Помилка № 5: намагатися використовувати === із value class.
Посилальна рівність для inline value classes беззмістовна й заборонена, бо такі значення можуть бути представлені по-різному (як значення або як обгортка). Для порівняння використовуйте == — і зазвичай це рівно те, що вам потрібно за змістом.
Помилка № 6: обгортати все підряд і зробити API незручним.
Якщо ви загорнули у value class кожен рядок і кожне число, у вас зʼявиться купа «шумних» перетворень і .value. Типобезпечність має бути точковою: захищайте ті місця, де помилка справді ймовірна й дорого коштує (ID, гроші, ключі, токени), а не перетворюйте весь код на пакувальний цех.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ