JavaRush /Курсы /Kotlin SELF /typealias и value class: читаемость и типобезопасность

typealias и value class: читаемость и типобезопасность

Kotlin SELF
33 уровень , 4 лекция
Открыта

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, деньги, ключи, токены), а не превращайте весь код в упаковочный цех.

1
Задача
Kotlin SELF, 33 уровень, 4 лекция
Недоступна
Метка категории
Метка категории
1
Задача
Kotlin SELF, 33 уровень, 4 лекция
Недоступна
Псевдоним ловушка
Псевдоним ловушка
1
Задача
Kotlin SELF, 33 уровень, 4 лекция
Недоступна
Деньги без минуса
Деньги без минуса
1
Задача
Kotlin SELF, 33 уровень, 4 лекция
Недоступна
Пропуск по шаблону
Пропуск по шаблону
1
Опрос
`data class` и `value class`, 33 уровень, 4 лекция
Недоступен
`data class` и `value class`
`data class` и `value class`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ