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 совместим по присваиванию с исходным типом и с другими alias’ами того же исходного типа. Kotlin прямо подчёркивает разницу: typealias — это просто альтернативное имя существующего типа, а не новый тип.
То есть вот такой код… скомпилируется:
typealias UserId = String
typealias ProductId = String
fun printUser(id: UserId) {
println("user=$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 должен иметь одну единственную property, инициализируемую в 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, деньги, ключи, токены), а не превращайте весь код в упаковочный цех.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ