1. Навіщо замінювати null, якщо є null-safety
Якщо ви пишете програму досить довго, то помічаєте одну підступну річ: null — це не лише «значення немає». На практиці null часто починає означати «щось пішло не так, але мені лінь пояснювати». І ось тут починається магія: код, що викликає, отримує T? і тепер змушений вгадувати, що саме сталося: «не знайдено», «не введено», «помилка парсингу», «відмовлено в доступі» або «розробник забув реалізувати». Kotlin захищає нас від NPE, але не здатен захистити від туманних контрактів.
Уявіть, що у вас є функція findExpenseById(id): Expense?. Це добрий і чесний контракт: витрати з таким id може не бути. Але якщо у вас є функція saveExpense(expense): Expense? і вона повертає null за будь-якої проблеми, то це вже «чорна скринька». Чому null? Що робити далі? Повторити? Показати повідомлення? Упасти? І що особливо неприємно — із сигнатури ви цього не дізнаєтеся.
У цій лекції ми розберемо три практичні заміни null. Кожна з них відповідає на конкретне запитання:
- передумови (require, check, requireNotNull, checkNotNull) відповідають на запитання «це взагалі можна викликати ось так?»;
- sealed‑результати відповідають на запитання «які осмислені результати може мати операція?»;
- Result<T> відповідає на запитання «як повернути успіх/помилку як дані, не перетворюючи все на винятки й не втрачаючи причини?».
2. Передумови: require, check і *NotNull
Передумови — це не «валідація введення користувача», а захист контракту вашої функції. Це спосіб сказати: «Якщо сюди прийшли погані дані — це не “життєва ситуація”, а помилка використання API (або баг у коді вище). Я краще впаду одразу, але зі зрозумілим повідомленням, ніж потягну сміття далі й зламаюся через 20 рядків у дивному місці».
Саме тому передумови найчастіше живуть усередині доменної логіки, де вже «не повинно бути поганих даних», і на межі між шарами — там, де ви хочете чітко відділити «сирий світ» від «строгого».
require(...): перевіряємо аргументи
Невелика «шпаргалка за змістом»: require — це «ви неправильно викликали функцію». Він викидає IllegalArgumentException.
Уявімо наш навчальний CLI‑проєкт (практичний проєкт курсу) — трекер витрат. Маємо модель:
data class Expense(
val id: Int,
val title: String,
val amountCents: Int
)
Нехай доменна функція створює витрату. Ми хочемо гарантувати, що витрату не можна створити з порожньою назвою та відʼємною сумою:
fun createExpense(id: Int, title: String, amountCents: Int): Expense {
require(id > 0) { "id має бути додатним" }
require(title.isNotBlank()) { "title не має бути порожнім" }
require(amountCents > 0) { "amountCents має бути додатним" }
return Expense(id = id, title = title.trim(), amountCents = amountCents)
}
Тут важливо розуміти: require — це не «мʼяко обробити». Це «зупинити програму, бо хтось явно помилився». Якщо ви зараз думаєте: «Ну так не можна ж падати через введення користувача!» — ви маєте рацію. Але тут ідеться не про введення. Це про шар, де вхідні дані вже мали бути перевірені.
check(...): перевіряємо стан та інваріанти
check — це «моя програма опинилася в неможливому стані». Він викидає IllegalStateException. На практиці check часто застосовують тоді, коли обʼєкт уже створено й він має бути коректним, але ви хочете підстрахуватися на випадок логічної помилки.
Наприклад, у нас є репозиторій витрат, який зберігає список. Ми очікуємо, що id унікальні. Якщо ми знайшли дві витрати з одним id, це не «помилка користувача», а помилка нашої логіки:
fun findUniqueById(items: List<Expense>, id: Int): Expense? {
val matches = items.filter { it.id == id }
check(matches.size <= 1) { "BUG: знайдено дубльований id=$id (${matches.size} елементів)" }
return matches.firstOrNull()
}
Так, тут усе ще повертається Expense?, бо «не знайдено» — це нормальна ситуація. Водночас ми захищаємо інваріант «дублікатів бути не повинно».
requireNotNull і checkNotNull: переклад T? → T без !!
Дуже поширений біль: ви отримали String? (наприклад, із парсингу або з Map.get()), але у цій точці він зобовʼязаний бути не null. Замість !! краще використовувати requireNotNull/checkNotNull — тому що вони не лише перетворюють T? на T, а й дають змогу написати зрозуміле, людське повідомлення.
fun normalizeCommand(raw: String?): String {
val text = requireNotNull(raw) { "Командний рядок не має бути null" }
return text.trim()
}
На відміну від raw!!, у разі падіння ви побачите осмислену причину, а не «NullPointerException десь там».
Elvis і Nothing: «або значення, або ми падаємо»
Ще один корисний патерн — «Elvis + функція, яка завжди падає». Це поєднує тему передумов із типом Nothing: вираз throw ... має тип Nothing, і тому чудово працює в правій частині Elvis. У документації Kotlin це показують на прикладі fail(message): Nothing, який завжди викидає виняток.
3. sealed‑результати замість null
Іноді проблема не в тому, що «значення немає». Проблема в іншому: результатів в операції кілька, і кожен має свій сенс. Якщо ви кодуєте все через null, ви втрачаєте зміст і змушуєте того, хто викликає, вгадувати.
sealed class дозволяє зробити контракт таким: «результат завжди є, але він буває різним». А Kotlin змушує вас обробити всі варіанти (вичерпний when), якщо ви не залишаєте лазівки через else.
Видалення за id: кілька нормальних результатів
Видалення витрати — чудовий приклад: можливі різні результати. Наприклад:
- видалили успішно;
- такого id немає;
- id некоректний (наприклад, <= 0) — і це радше помилка використання API.
Некоректний id ми захистимо require, а «немає такої витрати» — виразимо як варіант результату.
sealed class RemoveExpenseResult {
data object Removed : RemoveExpenseResult()
data object NotFound : RemoveExpenseResult()
}
fun removeExpenseById(items: MutableList<Expense>, id: Int): RemoveExpenseResult {
require(id > 0) { "id має бути додатним" }
val index = items.indexOfFirst { it.id == id }
if (index == -1) return RemoveExpenseResult.NotFound
items.removeAt(index)
return RemoveExpenseResult.Removed
}
Зверніть увагу: тут немає null взагалі. Ми повертаємо конкретний зміст.
Тепер CLI‑шар може акуратно сформувати повідомлення для користувача:
fun describeRemove(result: RemoveExpenseResult, id: Int): String = when (result) {
RemoveExpenseResult.Removed -> "Видалено витрату id=$id"
RemoveExpenseResult.NotFound -> "Немає витрати з id=$id"
}
when вичерпний, тому що sealed закриває набір варіантів. Якщо ви додасте новий варіант, компілятор сам нагадає, де ви забули обробити його. Це такий «будильник якості», який неможливо проігнорувати (на відміну від коментаря TODO обробити помилки потім).
Патерн Ok/Error, але без T? усередині Ok
Багатьом подобається шаблон Ok/Error. Він зручний, але тут є типова пастка: зробити Ok(value: T?). Тоді ви знову тягнете nullable всередину «успіху» — і вся ідея втрачається.
Зробімо результат додавання витрати в репозиторій. Припустімо, у нас заборонені дублікати id.
sealed class AddExpenseResult {
data class Ok(val added: Expense) : AddExpenseResult()
data class Error(val message: String) : AddExpenseResult()
}
fun addExpense(items: MutableList<Expense>, expense: Expense): AddExpenseResult {
val exists = items.any { it.id == expense.id }
if (exists) return AddExpenseResult.Error("Витрата з id=${expense.id} уже існує")
items.add(expense)
return AddExpenseResult.Ok(added = expense)
}
Тут Ok гарантовано містить не null Expense. А Error несе текст помилки як дані (а не як println усередині функції — про це ми ще поговоримо в розділі «типові помилки»).
Чому sealed краще за null для сценарію «помилка/не знайдено»
Найчастіша причина вибрати sealed замість T? — це ситуація, коли у вас є два різні значення “відсутності”:
- «не знайдено» (нормально);
- «помилка» (потрібно повідомити, можливо — із причиною).
Якщо ви зробите Expense?, ви не зможете відрізнити «не знайшли» від «не змогли через помилку валідації/логіки». Так, можна «друкувати в консоль», але тоді функція стає нетестованою й шумною. Краще повертати зміст у типах.
4. Result<T>: помилка як значення
Result<T> — це стандартний контейнер Kotlin: або успіх із T, або неуспіх із Throwable. Він зручний там, де помилка природно виражається винятком (наприклад, парсинг числа через toInt()), але вам хочеться повертати її як дані, не пишучи try/catch на кожному кроці.
По суті, це компроміс: ви не відмовляєтеся від винятків як механізму (вони все ще «всередині»), але назовні віддаєте охайний контракт «успіх/помилка».
Парсинг: Result<Int> замість Int?
На ранніх етапах курсу ми часто робили toIntOrNull() і отримували Int?. Це нормально, коли вам достатньо самого факту «вийшло/не вийшло». Але іноді важлива причина: порожній рядок? не число? занадто велике? відʼємне? І ви хочете це розрізняти.
Result дозволяє зберегти виняток і додати свої перевірки через mapCatching.
fun parsePositiveInt(raw: String): Result<Int> {
return runCatching { raw.trim().toInt() }
.mapCatching { value ->
require(value > 0) { "Очікувалося додатне ціле число" }
value
}
}
Тут є важливий момент: ми використовуємо require усередині mapCatching. Якщо умова не виконується, це теж потрапляє у Failure. Це зручно, бо код, що викликає, отримує єдиний формат обробки.
Result у CLI‑шарі трекера витрат
Уявімо, що команда виглядає так: add <id> <title> <amountCents>. Ми читаємо рядок, розбиваємо його й намагаємося розпарсити.
fun parseAddArgs(parts: List<String>): Result<Triple<Int, String, Int>> {
return runCatching {
val id = parts[1].toInt()
val title = parts[2]
val amount = parts[3].toInt()
Triple(id, title, amount)
}
}
Так, тут може вилетіти IndexOutOfBoundsException або NumberFormatException, і вони акуратно опиняться всередині Result.
Далі ми перетворюємо цей результат на зрозуміле повідомлення:
fun describeParse(result: Result<Triple<Int, String, Int>>): String {
return result.fold(
onSuccess = { "Розпарсено: id=${it.first}, title=${it.second}, amount=${it.third}" },
onFailure = { "Некоректні аргументи команди: ${it.message ?: "невідома помилка"}" }
)
}
Це приклад, де Result корисний саме на межі введення: ми хочемо не падати, але й не мовчати.
Чому Result<T?> майже завжди погана ідея
Комбінація Result<T?> означає: «операція могла завершитися помилкою, а якщо не помилкою, то ще й значення може бути відсутнім». Рідко виходить хороший контракт, бо ви створюєте дві незалежні осі невизначеності: спочатку розберіться, чи був exception, а потім — чому null.
Зазвичай краще вибрати щось одне:
- якщо «може не бути» — використовуйте T? або sealed Found/Missing;
- якщо «може зламатися» — використовуйте Result<T> або sealed Ok/Error.
І лише якщо вам справді потрібно розрізняти «успіх, але порожньо» від «помилка» (наприклад, запит у кеш: запису може не бути, але це не помилка), тоді Result<T?> іноді виправданий. Але це вже рішення, яке потребує дуже чіткої документації контракту.
5. Як вибрати підхід
Коли ви вперше починаєте замінювати null, зʼявляється нова небезпека: прибрати його всюди одразу й зробити код «надто розумним». Тому корисно тримати в голові просту схему вибору: що саме означає «немає значення» у вашому випадку і хто винен — користувач чи програміст.
Нижче — практична таблиця (не «академічна істина», а робочий орієнтир):
| Ситуація | Найкращий інструмент | Чому |
|---|---|---|
| Усередині коду це значення зобовʼязане бути (порушення — баг/неправильний виклик) | |
Fail‑fast: падіння одразу й у зрозумілому місці |
| Операція має кілька смислових результатів (знайшли/не знайшли, ок/помилка, доступ заборонено, конфлікт) | |
Типи змушують обробити всі варіанти, не втрачаємо змісту |
| Помилка природно виражається винятком, але ви хочете «помилка як дані» | |
Зручно на межах, зберігаємо |
Тепер важлива думка про архітектуру: ці підходи можна поєднувати, але робити це варто пошарово.
У нашому трекері витрат це виглядає логічно так: парсинг введення користувача повертає Result, бо введення брудне й непередбачуване. Доменна операція повертає sealed‑результат, бо там важливі смислові результати (наприклад, «дублікат id»). А передумови захищають інваріанти й контракти функцій там, де «так бути не повинно».
Збираємо все в один сценарій
Тепер зберемо шматочки в один невеликий потік. Нехай у нас є команда додавання витрати. Введення ми парсимо через Result, модель створюємо із захистом require, а в репозиторії повертаємо sealed‑результат.
fun handleAddCommand(items: MutableList<Expense>, parts: List<String>): String {
val parsed = parseAddArgs(parts)
return parsed.fold(
onSuccess = { (id, title, amount) ->
val expense = createExpense(id, title, amount)
when (val addRes = addExpense(items, expense)) {
is AddExpenseResult.Ok -> "Додано: ${addRes.added}"
is AddExpenseResult.Error -> "ПОМИЛКА: ${addRes.message}"
}
},
onFailure = { "ПОМИЛКА: некоректні аргументи (${it::class.simpleName})" }
)
}
Тут багато цікавого, хоча код короткий: ми не використовуємо null як «універсальну відповідь». Кожен шар говорить своєю мовою:
- парсер говорить «успіх/виняток» (Result);
- доменна фабрика говорить «або створюємо коректно, або це баг/порушення контракту» (require);
- репозиторій говорить «успіх або помилка бізнес‑правила» (sealed Ok/Error).
І, що важливо, код, що викликає, не вгадує. Він просто обробляє варіанти.
6. Типові помилки
Помилка №1: використовувати requireNotNull як «валідацію введення користувача».
Коли користувач вводить щось не так, це нормальна життєва ситуація, а не привід падати з IllegalArgumentException. Передумови доречні там, де погані дані означають баг або неправильне використання функції, а не «користувач помилився в цифрі». Для введення краще Result або sealed Error.
Помилка №2: робити sealed‑результат, але ховати зміст назад у null.
Класика жанру: sealed class Result { data class Ok(val value: T?) ... }. На папері ви «пішли від null», а на ділі просто перепакували null у коробку з написом «Ok». Якщо у вас Ok, там має бути повноцінне значення. Якщо значення може бути відсутнім — це окремий варіант (Missing, NotFound).
Помилка №3: повертати null, а причину друкувати через println усередині функції.
Такий код складно тестувати, складно перевикористовувати й неможливо нормально обробляти на рівні UI/CLI. Функція має або повертати зрозумілий результат із причиною (текст/тип помилки), або падати (якщо це передумова), але не «шепотіти в консоль і зникати в тумані».
Помилка №4: перетворювати Result на «глушник помилок».
runCatching легко використовувати як килим, під який замітається все. Якщо ви робите runCatching { ... }.getOrNull() і мовчки ігноруєте Failure, ви знову повертаєтеся до null, тільки тепер ще й втрачаєте причину. Якщо вже вибрали Result, поважайте його: обробляйте onFailure, додавайте контекст і формуйте повідомлення.
Помилка №5: змішувати стилі без домовленості й отримувати «три рівні невизначеності».
Наприклад, функція повертає Result<User?>, де null означає «не знайдено», а Failure означає «помилка», а ще всередині User є name: String? «про всяк випадок». У підсумку ви змушені перевіряти все тричі. Зазвичай краще зробити одну ясну модель: або sealed Found/Missing, або Result<User>, а всередині доменної моделі — строгі non‑null поля.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ