JavaRush /Курси /Kotlin SELF /Result<T> — як зберігати статус «успіх/помилка»

Result<T> — як зберігати статус «успіх/помилка»

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

1. Вступ

Коли ви лише починаєте програмувати, винятки часто сприймаються як блискавка: «щось десь вдарило — і програма впала». Та зі зростанням проєкту зʼясовується, що частина помилок — це цілком звичайні життєві ситуації. Користувач увів не число, попросив видалити запис, якого немає, або вказав порожню категорію. Це не «кінець світу». Це просто «немає результату» — але з поясненням.

І ось тут зʼявляється типова дилема. Якщо всюди кидати винятки, код викликача вашої функції перетворюється на ліс try/catch. Якщо ж усюди повертати null, сенс швидко розмивається: чому null? «Не знайдено»? «Некоректне введення»? «Зламалася логіка»? Kotlin навіть у прикладах до стандартної бібліотеки показує, що інколи краще користуватися безпечними альтернативами на кшталт toIntOrNull() замість падіння на toInt() — саме тому, що «не число» часто є очікуваною ситуацією.

Result<T> — один із інструментів, який допомагає сказати: «Функція може завершитися успіхом або помилкою, і я поверну це як значення, а не як вибух».

2. Що таке Result<T>: значення або помилка в одній коробці

Уявіть собі коробку з наліпкою «Обережно». Усередині — або корисна річ (наприклад, Int), або папірець із поясненням, чому річ не вийшла (у вигляді Throwable). Це і є Result<T>: контейнер, що зберігає один із двох наслідків виконання — успіх із T або помилку з винятком.

Важлива думка: Result<T> — це не магія і не універсальна заміна всім підходам. Це лише форма передавання результату, яка дозволяє не змушувати кожного викликача писати try/catch, якщо ситуація очікувана. Водночас «помилка» лишається не рядком і не null, а повноцінним винятком (тип + повідомлення + потенційна причина). Це зручно, адже винятки в Kotlin мають багату екосистему типів (NumberFormatException, IllegalArgumentException тощо).

Схематично це можна уявити так:

flowchart TD
    A[Функція щось робить] --> B{Вийшло?}
    B -->|так| C["Result.success(value)"]
    B -->|ні| D["Result.failure(exception)"]
    C --> E[Код викликача вирішує: взяти значення, підставити fallback або зупинитися]
    D --> E

3. Як створити Result: success(...) і failure(...)

Створювати Result дуже просто. Є два базові фабричні способи:

  • Result.success(value) — запакували значення.
  • Result.failure(exception) — запакували помилку.

Почнімо з мікроприкладу, не повʼязаного з проєктом, — просто щоб побачити механіку й не відволікатися на доменну логіку.

fun main() {
    val ok: Result<Int> = Result.success(10)
    val bad: Result<Int> = Result.failure(IllegalArgumentException("ні"))

    println(ok)   // Success(10)
    println(bad)  // Failure(java.lang.IllegalArgumentException: ні)
}

Це ще не «гарний застосунок», але вже видно: у змінній лежить наслідок, а не «вибухнуло/не вибухнуло».

Тепер важливий практичний нюанс: що якісніший виняток усередині failure, то простіше потім зрозуміти, що сталося.

4. Повертаємо Result із функцій на прикладі проєкту

Щоб приклади не були «у вакуумі», продовжимо наш практичний консольний проєкт (умовно назвімо його BudgetBuddy): він зберігає витрати й уміє приймати команди add, list, remove. Ми не ліземо у файли й мережу — це будуть інші дні курсу — тож сховище буде просто в памʼяті.

У домені в нас є модель витрати:

data class Expense(
    val id: Int,
    val amount: Int,
    val category: String,
    val note: String
)

Тепер уявімо типову задачу: користувач вводить суму текстом, а ми хочемо розпарсити її в Int. Якщо зробити це напряму через toInt(), то за неправильного введення отримаємо NumberFormatException.

У ранніх лекціях ми вже бачили варіант «мʼякого» парсингу через toIntOrNull(). Тепер зробимо версію з явним контрактом: повернемо Result<Int>, де в разі помилки буде виняток зі зрозумілим повідомленням.

fun parseAmount(text: String): Result<Int> {
    val trimmed = text.trim()
    val n = trimmed.toIntOrNull()
        ?: return Result.failure(NumberFormatException("Сума не є числом: '$text'"))

    if (n <= 0) {
        return Result.failure(IllegalArgumentException("Сума має бути > 0, отримано $n"))
    }

    return Result.success(n)
}

Зверніть увагу на стиль: ми не кидаємо виняток назовні, а пакуємо його як значення. І ще один момент: ми додаємо фактичне значення («отримано …»). Це пришвидшує діагностику, бо з повідомлення одразу видно, що саме прийшло на вхід.

5. Result як контракт: функція не вирішує долю помилки

Дуже легко неправильно зрозуміти Result: ніби він «обробляє помилку». Насправді ні — він не обробляє. Він просто акуратно каже: «Ось підсумок, далі вирішуйте ви».

Щоб відчути це, напишемо функцію, яка створює витрату, але повертає Result<Expense>. Усередині вона використовує parseAmount.

fun createExpense(id: Int, amountText: String, category: String, note: String): Result<Expense> {
    val amountResult = parseAmount(amountText)
    val amount = amountResult.getOrElse { return Result.failure(it) }

    val cat = category.trim()
    if (cat.isEmpty()) {
        return Result.failure(IllegalArgumentException("Категорія не має бути порожньою"))
    }

    return Result.success(Expense(id = id, amount = amount, category = cat, note = note.trim()))
}

Тут сталася важлива штука: ми застосували getOrElse як спосіб дістати значення або вийти через помилку. Це схоже на стиль guard clauses: щойно бачимо, що далі не можна коректно продовжувати, — акуратно повертаємо помилку, не роздуваючи метод величезним if.

Так, ми могли б зробити це через виняток. Але тоді змусили б викликача писати try/catch (або програма зробила б «бах» — і все). А наша мета — зробити контракт явно читабельним прямо в типі: Result<Expense>.

6. Робота з Result у CLI: витягування, розгалуження й пайплайни

Коли Result потрапив до коду викликача, у нас зʼявляється вибір: дістати значення, підставити запасний варіант, показати повідомлення й завершити сценарій або знову «підірвати» помилку (якщо на цьому рівні її вже вважають аварійною).

Тут важливо не діяти за принципом «а, гаразд, поставлю всюди нуль». Нуль інколи — чудове рішення, а інколи — прихована помилка, яка потім перетворюється на зламану статистику й загадкові звіти.

Нижче — таблиця основних методів, які ми сьогодні використовуємо:

Метод Що повертає Коли доречний Типовий ризик
getOrNull()
T?
коли помилка «не важлива» і можна жити з null втратити причину помилки
exceptionOrNull()
Throwable?
коли хочемо окремо дістати помилку й показати/залогувати забути обробити успіх
getOrElse { ... }
T
коли є осмислений fallback або хочемо вийти з функції зробити «універсальну милицю»
getOrThrow()
T
або кине виняток
коли на цьому рівні помилка вже аварійна перетворити Result на беззмістовну обгортку

Тепер подивімося на це на коротких прикладах.

getOrNull() — перетворюємо Result<T> на T?

Ця штука корисна, коли ми свідомо хочемо «мʼякої поведінки»: або число, або null. У Kotlin такий стиль уже знайомий за toIntOrNull() і за OrNull-методами колекцій.

fun main() {
    val n: Int? = parseAmount("123").getOrNull()
    println(n) // 123

    val bad: Int? = parseAmount("x").getOrNull()
    println(bad) // null
}

Мінус очевидний: причина втрати значення зникла. Тому getOrNull() — це свідомий вибір: «мені не важлива причина».

exceptionOrNull() — дістаємо виняток як дані

Якщо нам важливо пояснити користувачу, чому команда не виконалася, зазвичай хочеться отримати текст помилки.

fun main() {
    val r = parseAmount("x")
    val msg = r.exceptionOrNull()?.message ?: "OK"
    println(msg) // Сума не є числом: 'x'
}

Зверніть увагу: exceptionOrNull() повертає Throwable?, тож безпечні виклики ?. тут дуже доречні.

getOrElse { ... } — осмислений fallback або ранній вихід

getOrElse добрий тоді, коли ви справді знаєте, що робити в разі помилки. Наприклад, якщо користувач не ввів нотатку, можна підставити «(no note)». Але з грошима так робити небезпечно.

Покажемо варіант, де fallback справді розумний:

fun normalizedNote(note: String): Result<String> =
    if (note.isBlank()) Result.failure(IllegalArgumentException("Порожня нотатка"))
    else Result.success(note.trim())

fun main() {
    val note = normalizedNote("   ").getOrElse { "(no note)" }
    println(note) // (no note)
}

Тут fallback — частина UX, а не «замітаємо проблему під килим».

getOrThrow() — повернути нас у світ винятків

Інколи Result потрібен на нижчому рівні, щоб акуратно зібрати помилку, але на верхньому рівні ми вирішуємо: «Якщо тут помилка — стоп, далі не можна». Тоді getOrThrow() чесно кине виняток назовні.

fun main() {
    val amount = parseAmount("x").getOrThrow()
    println(amount) // сюди не дійдемо
}

Чому це не безглуздо? Бо політика може бути різною на різних шарах. Нижній шар може «пакувати» помилки, а верхній — вирішити, що в цій точці сценарій має завершитися.

onSuccess {} і onFailure {}: розгалуження без try/catch

Коли ви пишете консольний інтерфейс, вам часто потрібно зробити просту річ: якщо успіх — друкуємо OK, якщо помилка — друкуємо ERROR: …. І ось тут onSuccess / onFailure дозволяють не перетворювати все на ланцюжки if.

Зробімо маленьку функцію, яка друкує результат виконання команди:

fun printCommandResult(result: Result<String>) {
    result
        .onSuccess { println("OK: $it") }
        .onFailure { println("ERROR: ${it.message}") }
}

І приклад використання:

fun main() {
    val ok = Result.success("Витрату додано")
    val bad = Result.failure(IllegalArgumentException("Неправильна команда"))

    printCommandResult(ok)   // OK: Витрату додано
    printCommandResult(bad)  // ERROR: Неправильна команда
}

Це ще не «повний застосунок», але вже видно: розгалуження стало акуратним і локальним. Ми не зобовʼязані ловити виняток — він уже «лежить у коробці», а ми просто вирішуємо, як його показати.

Міні‑пайплайн команди add у main

Зберімо невеликий шматок, схожий на справжнє введення команд. Нагадаю: у попередніх днях ми вже робили розбір рядка, нормалізацію, split, команди тощо. Тут лише покажемо, як Result допомагає не перетворити main на величезний try/catch.

Команда:
add <amount> <category> <note...>

Наприклад:
add 120 food Coffee

fun handleAddCommand(input: String, nextId: Int): Result<Expense> {
    val parts = input.trim().split(" ")
    if (parts.size < 4) {
        return Result.failure(IllegalArgumentException("Використання: add <amount> <category> <note...>"))
    }

    val amountText = parts[1]
    val category = parts[2]
    val note = parts.drop(3).joinToString(" ")

    return createExpense(id = nextId, amountText = amountText, category = category, note = note)
}

І ось як це може виглядати в main:

fun main() {
    val input = "add x food Coffee"
    val result = handleAddCommand(input, nextId = 1)

    result
        .onSuccess { println("Додано: $it") }
        .onFailure { println("Не вдалося додати: ${it.message}") }
    // Не вдалося додати: Сума не є числом: 'x'
}

Тут важливо, що помилка «не число» — це NumberFormatException, типовий випадок збою формату. Але ми не даємо йому «підірвати» програму. Натомість перетворюємо на керований наслідок.

Як не перетворити Result на «ховач помилок»

Result легко використати неправильно: загорнути помилку, щось пробурмотіти в консоль і продовжити так, ніби все гаразд. У результаті програма стає дуже «живучою»… але починає брехати. Це майже як заклеїти індикатор «Check engine» ізоляційною стрічкою: тихіше стало, а проблема нікуди не зникла.

Щоб цього уникнути, корисно тримати в голові правило: після помилки завжди має бути рішення. У CLI‑програмі рішень зазвичай три: показати повідомлення й продовжити цикл, показати повідомлення й завершити програму або підставити осмислений fallback. Якщо ви зробили onFailure { } і нічого не зробили всередині, то ніби сказали: «Я побачив пожежу й вирішив: хай далі горить, я просто не дивитимуся».

Міні‑приклад «правильної дисципліни» — перетворювати Result на рядок, який уже друкує CLI:

fun toCliMessage(result: Result<Expense>): String {
    val e = result.exceptionOrNull()
    return if (e != null) {
        "ERROR: ${e.message}"
    } else {
        val expense = result.getOrThrow()
        "OK: added id=${expense.id}, amount=${expense.amount}"
    }
}

Тут є тонкість: ми спочатку перевірили помилку, а потім уже викликали getOrThrow(). Тобто «вибухнемо» лише тоді, коли порушимо власну логіку (коли чомусь помилка null, а значення немає — що зазвичай не трапляється для коректного Result). Цей стиль схожий на fail-fast: падати треба там, де зламалася логіка програми, а не там, де користувач просто ввів x замість числа.

7. Типові помилки під час роботи з Result<T>

Помилка № 1: перетворювати Result на null через getOrNull() і забувати, що взагалі була помилка.
Таке часто стається, коли хочеться «ну щоб просто працювало». У підсумку ви втрачаєте причину й позбавляєте себе можливості нормально пояснити користувачу, що не так. Якщо для UX важливо «чому», краще дістати exceptionOrNull() і сформувати повідомлення, а не мовчки отримувати null.

Помилка № 2: ставити getOrElse { 0 } як універсальну милицю.
Для суми витрат 0 може виглядати невинно, але це здатне приховати реальну проблему: наприклад, користувач увів сміття, а ви тихо записали витрату на 0. Потім звіти «чомусь» не сходяться. Fallback має бути осмисленим у контексті задачі, а не просто зручним для компілятора.

Помилка № 3: писати Result.failure(Exception("ой")) без сенсу і без фактичних даних.
Повідомлення oops може бути смішним рівно перші 3 секунди — доки ви не почнете налагоджувати. Нормальне повідомлення має казати, що очікувалося і що отримали, зокрема й фактичне значення (… отримано …).

Помилка № 4: використовувати onFailure { } як «глушник» і продовжувати як ні в чому не бувало.
Result не зобовʼязаний «валити» програму — і це його плюс. Але якщо в разі помилки ви не обрали політику (зупинитися, продовжити, fallback), то ви не обробили помилку, а сховали її. У такий момент баги починають подорожувати кодом тихо — і знаходять вас у найнеприємніший день.

Помилка № 5: повертати Result із функції, а в першому ж місці виклику робити getOrThrow() всюди підряд.
Так ви перетворюєте Result на зайву обгортку: формально ви «перенесли» виняток у значення, але одразу повернули виняток назад — без виграшу в читабельності. Якщо вже ви обрали контракт Result, дайте коду викликача шанс свідомо обробити наслідок через onSuccess/onFailure, getOrElse або перетворення на зрозуміле повідомлення.

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