Помилки, які ми моделюємо неправильно

Помилки, які ми моделюємо неправильно

Опубліковано 29.12.2025 | Востаннє оновлено 03.01.2026

Гарди, валідація, обробка помилок — ми всі це робимо. Ми кидаємо ексепшни, повертаємо null, загортаємо значення в Result або просто сподіваємося, що той, хто викликає наш код, "зробить все правильно". Найчастіше ми навіть не замислюємося про це — просто слідуємо знайомим патернам і йдемо далі.

Водночас постійно існує певна напруга:

  • "Ексепшни — це зло"
  • "Тут має бути код без ексепшнів"
  • "Ми мусимо обробляти всі помилки явно"

Але що це насправді означає?

Не кожен фейл (failure) — це помилка. Не кожен ексепшн є чимось винятковим. І не кожна небезпечна операція заслуговує на те, щоб її обгортали в Result тільки для того, щоб почуватися безпечніше. Насправді ми маємо справу з чимось більш фундаментальним: порушенням контрактів між частинами нашої програми.

Функція щось очікує від вхідних даних. Той, хто викликає, щось очікує від результату. I/O розраховує на те, що зовнішній світ поводитиметься адекватно. Рано чи пізно ці припущення ламаються.

Kotlin дає нам безліч способів виразити це "щось": кинути виняток, повернути null, відкрити небезпечні операції, загорнути результат або змусити клієнта явно обробити помилку через систему типів. Жоден із цих способів не є універсально правильним чи неправильним — але кожен із них по-різному комунікує відповідальність.

Ця стаття не про заборону ексепшнів чи поклоніння типам. Вона про розуміння того, який контракт ви визначаєте, хто відповідає за його порушення і наскільки явним має бути ваше API.

Що таке контракт?

Почнімо з визначення того, що ми маємо на увазі під контрактом:

Контракт — це домовленість між функцією та тим, хто її викликає (caller). Вона визначає, які вхідні дані дозволені, який результат гарантується, якщо ці дані валідні, і що відбувається, коли ці очікування порушуються. Контракт описує припущення, які робить функція, обіцянки, які вона дає натомість, і те, як виражається невдача, коли ці припущення не справджуються.

Ми можемо розділити контракт на три основні пункти:

  1. Передумови (Preconditions): Умови, які має виконати клієнт перед викликом. Наприклад, List<E>.first() зі стандартної бібліотеки очікує, що ви перевірили або впевнені, що List<E> містить хоча б один елемент.
  2. Постумови (Postconditions): Що викликана функція гарантує у відповідь. Повертаючись до попереднього прикладу, List<E>.first() гарантує, що для непорожнього списку вона поверне перший елемент.
  3. Семантика невдачі (Failure semantics): Що відбувається, якщо контракт порушено (функція кидає ексепшн, повертає null / type-safe result або просто "кладе" всю програму).

Важливо розуміти, що контракт — це не про валідацію на кшталт require(...) чи check(...) у коді, а скоріше про визначення що дозволено, що обіцяно і хто винен, коли щось йде не так. Іноді у вас може навіть не бути жодної валідації (чого краще уникати; fail-fast все ще актуальний), але це не змушує саме поняття "контракту" магічно зникнути.

Типи порушення контракту

Оскільки ця стаття передусім про обробку порушень контракту, поговорімо про різні їх типи.

Ввід користувача (User Input)

Ввід користувача — це найбільш очевидне і найбільш обговорюване джерело порушень контракту. І не дарма. Користувачі не є частиною вашої програми. Вони не знають ваших інваріантів, не поважають ваші обмеження і з радістю нададуть дані, які порушують будь-яке припущення, яке тільки може зробити ваш код.

У цьому випадку порушення контракту є очікуваними, частими та не винятковими. Порожнє поле, невалідний email, від'ємне число там, де мають сенс лише додатні значення — нічого з цього не є сюрпризом. Це частина нормального потоку виконання (control flow). Саме тому ввід користувача — це зазвичай те місце, де ми:

  • валідуємо "жадібно" (eagerly),
  • повідомляємо про помилки явно,
  • і уникаємо кидання ексепшнів, де це можливо.

Тут кидання винятку часто означає, що ви втратили контроль. Контракт було порушено, але порушення було передбачуваним, і ваше API має відображати цю реальність. Повернення типізованої помилки, результату валідації або failure-значення — це зазвичай найбільш чесний підхід.

Важлива частина — це відповідальність: клієнт (користувач) володіє порушенням. Це користувач порушив контракт, а не система.

Помилка програміста (Programmer Error)

Помилки програміста живуть в іншій реальності. Це внутрішні помилки (логічні баги), які зазвичай призводять до ексепшнів. На відміну від вводу користувача, який час від часу очікувано буває невалідним, помилки програміста вказують на те, що щось пішло не так всередині вашої програми, і ви зазвичай не обробляєте їх мовчки.

Припустімо, у вас є функція, яка встановлює ширину в'юшки (view), і вона кидає виняток, якщо ширина від'ємна:

fun setWidthPx(view: View, widthPx: Int) {
    require(widthPx >= 0) { "Width must be non-negative" }
    view.layoutParams = view.layoutParams.apply { this.width = widthPx }
}

Тепер уявімо клієнта, який розраховує ширину, але забуває обробити крайній випадок:

val parentWidth = parent.measuredWidth
val padding = 20
val desiredWidth = parentWidth - padding * 2  // ой, parentWidth менше ніж padding * 2
setWidthPx(myView, desiredWidth) // 💥 throws: Width must be non-negative

Функція коректна, той хто викликає — "нормальний", але контракт порушено. Це реальна помилка програміста, і це не те, від чого ваша програма повинна намагатися мовчки відновитися — хіба що шляхом недопускання таких помилок у майбутньому.

Збій середовища (Environmental failure)

Збої середовища — це зовсім інша історія. Вони походять від речей, які ви не контролюєте, як-от файлова система, мережа або залізо. На відміну від помилок програміста, жодна кількість ідеальної внутрішньої логіки не гарантує успіху. Навіть з бездоганним кодом ці операції все одно можуть впасти — і саме тому вони потребують явноної обробки.

Але навіть так, це не означає, що ми повинні переускладнювати наш код або вдавати, що цих збоїв не існує. Мета не в тому, щоб обгорнути все в шари типів або захисних перевірок — а в тому, щоб обробляти неминуче явно, чітко і в правильному місці.

Як обробляти порушення контракту?

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

Як це обробляється в коді, який ми використовуємо?

Почнемо з прикладів, які ми бачимо щодня.

Standard library

kotlin-stdlib — це те, з чого багато розробників черпають натхнення при створенні власних API. І не дивно — вона запровадила набір патернів, які тихо сформували те, як ми пишемо на Kotlin сьогодні.

Почнімо з функцій, які кидають ексепшни при порушенні контракту:

  • kotlin.Result<T>.getOrThrow() кидає інкапсульований Exception, якщо результат був невдалим. Очікується, що ви будете абсолютно впевнені в результаті, перевіривши isSuccess() перед викликом.
  • String.toInt() кидає NumberFormatException, якщо рядок не був валідним числом. Очікується, що ви перевірите рядок заздалегідь, будете впевнені у вхідних даних або використаєте інший варіант функції, який ми обговоримо нижче.
  • Iterable<Int>.max/min/first/last() кидають NoSuchElementException, якщо переданий iterable не мав жодного елемента.

Всі ці API покладаються на ваше знання стану, який ви передаєте. Якщо це знання виявляється помилковим, вони кидають виняток. Гучно і негайно.

Але кидання ексепшнів — не єдина опція. Для більшості цих функцій стандартна бібліотека також надає OrNull варіанти, явно сигналізуючи, що невдача є можливим і очікуваним результатом:

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

Трохи менш обговорюваний патерн — це OrElse, який відсуває цю відповідальність ще далі, до місця виклику:

val items = listOf("a", "b", "c")

val value = items.elementAtOrElse(5) { index ->
    "<missing at $index>"
}

Замість того, щоб кидати виняток або повертати null, функція просить вас визначити фолбек (fallback) явно.

Ці три патерни — OrThrow, OrNull та OrElse — формують основу підходу стандартної бібліотеки до порушень контракту. Kotlin не заганяє вас в єдину "правильну" стратегію. Натомість він дає вам кілька способів виразити, наскільки ви впевнені, хто володіє помилкою і наскільки явним ви хочете бачити своє API.

kotlinx.coroutines

kotlinx.coroutines не сильно відрізняються, і це не дивно — вони також створені командою Kotlin.

Ми бачимо ті самі патерни, а також деякі інші:

Як бачимо, є ще один патерн, який додає префікс try до свого звичайного аналога — наприклад, trySend. У kotlinx.coroutines цей патерн існує для того, щоб перетворити контракт на основі ексепшнів на явний, based-on-value контракт.

Оригінальна функція сигналізує про невдачу киданням винятку. Варіант try* зберігає ту саму операцію, але повертає значущий результат, дозволяючи клієнту обробити невдачу без покладання на ексепшни чи try/catch.


Це основні патерни, які ви знайдете в офіційних бібліотеках Kotlin. Найцікавіше починається, коли вам доводиться обирати один з них.

Цей вибір рідко стосується особистого смаку чи "exceptions vs types". Він про те, який вид невдачі ви моделюєте, хто нею володіє і що клієнт, як очікується, має знати або гарантувати.

Чи є невдача частиною нормального контролю потоку, чи вона вказує на зламане припущення? Чи очікується, що клієнт відновиться, чи це момент, коли програма повинна перестати вдавати, що все добре? Чи перетинає ця операція межу системи, чи залишається повністю всередині довіреного коду?

Відповідь на ці питання зазвичай робить патерн очевидним: throwing, OrNull, OrElse або try* перестає бути стилістичним вибором і стає частиною контракту, який ви визначаєте.

Реальність

Справжнє питання не в тому, які патерни існують, а в тому, де ви проводите межу.

На практиці більшість кодових баз не падають скрізь однаково. Вони падають на кордонах (boundaries).

Ввід користувача

Ввід користувача — це один з таких кордонів. Ми очікуємо невалідні дані, тому кидання ексепшну тут зазвичай є неправильним сигналом. "Ексепшн" тут не комунікує баг — це просто означає, що користувач надрукував щось дивне. Тому API на цьому краї природно тяжіють до type-safe результатів: вони роблять невдачу явною, очікуваною і локальною.

На цьому кордоні ми повинні змістити наш фокус з exception-first на safe-first:

@JvmInline
value class UserName private constructor(val rawString: String) {
	companion object {
		fun create(value: String): FactoryResult {
			return if (value.length !in 2..50) FactoryResult.InvalidLength else FactoryResult.Success(UserName(value))
		}
	}
	
	sealed interface FactoryResult {
		data object InvalidLength : FactoryResult
		data class Success(val value: UserName) : FactoryResult
	}
}

Що... прямо суперечить нашому раніше встановленому патерну. Але чи це погано?

Патерни на кшталт кидання ексепшну за замовчуванням і наявності OrNull чи try* хороші — коли вони застосовні. Стандартна бібліотека Kotlin є бібліотекою загального призначення і розроблена без урахування нашого контексту. Порушуючи патерн тут, ми нічим не ризикуємо: система типів нас захищає.

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

Простий контрприклад — це база даних. Коли дані приходять з бази даних, невалідні значення зазвичай сигналізують про баг, а не про типову помилку користувача. Оскільки вони мали бути провалідовані раніше.

Тому ми часто вводимо небезпечний (unsafe) варіант, який навмисно обходить наш type-safe результат:

@JvmInline
value class UserName private constructor(val rawString: String) {
	companion object {
		// ...
		fun createOrThrow(value: String): UserName {
			return if (value.length !in 2..50) throw IllegalArgumentException(...)
			else UserName(...)
		}
	}
	// ...
}

Він все ще має знайомий присмак Kotlin Standard Library, тісно узгоджуючись з функціями на кшталт Result<T>.getOrThrow(), які ми викликаємо лише тоді, коли впевнені, що результат мусить бути успішним — а будь-що інше вказувало б на баг.

Ми не змушуємо create кидати ексепшн, і не змушуємо try* повертати type-safe варіант за замовчуванням — ми не загальна бібліотека, як Kotlin Standard Library чи kotlinx.coroutines, яка не знає свого цільового використання. Ми знаємо, чого очікуємо, і в нашому випадку кожен додатковий варіант — це додатковий вибір — а за визначенням, кожен додатковий вибір — це можливість використати його неправильно. create є основною функцією: це те, що з'являється першим у автодоповненні коду, це перше, до чого тягнуться розробники, і це встановлює очікування безпечної обробки.

Внутрішній ввід (Internal Input)

Хоча більшість впливу на нашу систему походить від користувачів, це не означає, що нам потрібно застосовувати safe-first патерни скрізь або сліпо загортати кожен системний виклик у OrThrow. Це було б перебором (overkill).

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

@JvmInline
value class ConfirmationAttempts(val rawInt: Int) {
	init {
		require(rawInt > 0) { "Confirmation attempts cannot be negative." }
	}
}

Для ConfirmationAttempts цілком логічно знати, що спроби підтвердження не можуть бути від'ємними — його контракт чітко встановлений самим лише іменуванням. Ви могли б технічно загорнути це в createOrThrow, якщо хочете, але я зазвичай цього не роблю — тому що якщо кожне значення, яке належить системі, загортати таким чином, "сигнал уваги", який передає OrThrow, губиться — особливо якщо все, що кидає ексепшн, має такий сигнал. Це стає фоновим шумом: фактично, це те саме, що просто поставити require в блоці init, і ніхто цього більше не помічає.

Це не робить раніше обговорені патерни зі стандартної бібліотеки та kotlinx.coroutines застарілими. Ми використовуємо їх з тієї ж причини, що й ці бібліотеки — коли ми не впевнені, на якому кордоні це буде використовуватися — це ввід користувача? Чи це довірений кордон, де помилки не повинні траплятися?

Принцип простий: OrThrow — це спосіб спожити результат небезпечно, а не дефолт для маркування коду, що може впасти (throwable code).

Неконтрольований ввід (Uncontrolled Input)

Не всі дані надходять акуратно запакованими як "ввід користувача" або "власність системи". Іноді ми маємо справу з джерелами, які не є ані повністю довіреними, ані повністю під нашим контролем — зовнішні API або сторонні сервіси. Вони представляють сіру зону, де контракти існують, але ви не можете гарантувати зі свого боку, що все піде за планом.

Ми по суті ставимося до цього як до вводу користувача, але оскільки нам рідко важливо, що саме пішло не так (окрім логування, але прокинутого (propagated) ексепшну більш ніж достатньо), я зазвичай вводжу Result<T> у таких випадках:

class UserProfileRepository(
    private val cache: ConcurrentHashMap<Long, UserProfile>, // має бути краще кешування, просто приклад
    private val database: UserProfileDao,
    private val networkClient: UserProfileApi,
    private val logger: Logger,
) {
    suspend fun getUserProfile(userId: Long): Result<UserProfile> {
        // 1. In-memory кеш — це довірений стан системи
        val cached = cache[userId]
        if (cached != null) return Result.success(cached)

        // 2. I/O Бази Даних — це кордон Environmental Failure
        val dbEntity = suspendRunCatching { 
            database.getById(userId) 
        }.getOrElse { return Result.failure(it) }

        if (dbEntity != null) {
            // Мапінг знаходиться ЗЗОВНІ обгортки. Якщо це впаде, це Помилка Програміста.
            // І ми не хочемо, щоб це було проковтнуто.
            val profile = dbEntity.mapToDomain() 
            cache[userId] = profile
            return Result.success(profile)
        }

        // 3. Мережеве I/O — це найбільш поширений кордон Environmental Failure
        val response = suspendRunCatching { 
            networkClient.fetchUser(userId) 
        }.getOrElse { return Result.failure(it) }

        // Знову ж таки, мапінг ззовні, щоб гарантувати, що ми не ховаємо баги
        val profile = response.mapToDomain()

        // 4. Запис в БД (Best-effort Side-effect)
        // Персистентність — це вторинна турбота. Ми загортаємо і логуємо це, щоб 
        // збій диска/БД не позбавив користувача 
        // успішно отриманого результату.
        suspendRunCatching { 
            database.insert(profile) 
        }.onFailure { throwable ->
            logger.error("Failed to persist user profile to DB", throwable)
        }
        cache[userId] = profile

        return Result.success(profile)
    }
}

Критично важливо помітити, що ми не загортаємо всю функцію в один suspendRunCatching (кастомна альтернатива runCatching, яка не ковтає CancellationException, запобігаючи поломці structured concurrency). Натомість ми "хірургічно" загортаємо лише конкретні I/O кордони, де невдача є реальністю середовища.

Логіка мапінгу (mapToDomain) анонімно залишена зовні. Ми не очікуємо, що наш внутрішній мапінг впаде; якщо це станеться, це Помилка Програміста, а не збій рантайму. Тримаючи його поза обгорткою, ми гарантуємо, що додаток впаде (crashes) негайно, дозволяючи нам зловити баг, а не заглушити його всередині Result.failure.

Крім того, ви могли помітити, як ми обробили невдачу вставки в базу даних. Ми виражаємо те, що ця помилка не є настільки критичною; ми можемо вважати її винятком, який не сигналізує про винятковий стан, а скоріше про деградований стан некритичного сайд-ефекту. Оскільки основний контракт (доставка профілю користувача) вже був виконаний через мережевий запит і мапінг — збій на рівні персистентності є вторинним. Це best-effort операція, де перехідний стан локального диска не повинен ламати успішну доставку основного результату клієнту.

Зрештою, ви також можете запровадити той самий патерн, який ми застосовуємо для вводу користувача з sealed result — це залежить від того, наскільки вам важливо обробляти специфіку невдачі. Але логіка помилок програміста залишається незмінною.

Хибна Безпека (False Safety)

Ловити менше (Catch.. less)

Повертаючись до прикладу з попереднього розділу — а саме бази даних — чи справді ми можемо сказати, що кожна помилка, кинута вставкою чи будь-якою іншою операцією БД, є збоєм середовища, а не помилкою програміста?

Як ми встановили раніше з mapToDomain, ми явно не хотіли загортати все в suspendRunCatching. Причина проста: ми не хочемо ігнорувати помилки, які не є частиною неконтрольованого виводу.

База даних може "кидатися" (throw) у дуже різних сценаріях:

  • Збої середовища — тимчасові проблеми з мережею, вичерпання пулу з'єднань, рестарти бази, таймаути. Вони рідкісні, але вони трапляються, і вони значною мірою поза вашим контролем.
  • Помилки програміста, спричинені невалідною схемою або припущеннями — відсутні індекси, порушені констрейнти, несумісні типи колонок, неузгоджені міграції, некоректний SQL. Це вказує на порушений контракт всередині вашої системи, а не на нестабільне середовище.

Ставлення до обох категорій однаково — це те, де починає прокрадатися хибна безпека.

Так, ми хочемо, щоб наші користувачі не бачили крешів, спричинених речами, які вони не контролюють — і часто речами, які ми теж не контролюємо. Але це не означає, що ми повинні глушити збої наосліп. Інструменти на кшталт detekt чи ktlint попереджають вас не просто так: бути явним щодо того, що ви навмисно ігноруєте — це частина написання чесного коду.

Питання не в тому "чи має це впасти?", а скоріше "що саме я готовий тут заглушити?"

Розгляньмо цю просту функцію, яку я нещодавно імплементував:

public suspend inline fun <reified T : Enum<T>> R2dbcTransaction.createEnumTypeIgnoring() {
    val enumName = T::class.simpleName?.lowercase() ?: error("Enum must have a name")
    val enumValues = enumValues<T>().joinToString(",") { "'${it.name}'" }

    try {
        exec("CREATE TYPE $enumName AS ENUM ($enumValues)")
    }  catch (_: Exception) {
        // postgresql does not support CREATE TYPE IF NOT EXISTS, so we want to ignore such errors;
    }
}

На перший погляд, це виглядає розумно. Але в чому тут вада?

Ми ловимо надто широко. Ковтаючи Exception, ми не просто ігноруємо випадок "тип вже існує" — ми також ігноруємо:

  • синтаксичні помилки SQL
  • проблеми з правами доступу
  • розірвані з'єднання
  • неправильно налаштовані транзакції
  • або навіть баги, внесені майбутнім рефакторингом

Перше покращення може виглядати так:

try {
    exec("CREATE TYPE $enumName AS ENUM ($enumValues)")
}  catch (e: R2dbcException) {
	// postgresql does not support CREATE TYPE IF NOT EXISTS, so we want to ignore such errors;
}

Це краще, але все ще недостатньо. Ми тепер ігноруємо всі збої рівня бази даних — включно з тими, які абсолютно точно повинні спливти нагору.

Ми можемо звузити це ще більше:

try {
	exec("CREATE TYPE $enumName AS ENUM ($enumValues)")
}  catch (e: R2dbcException) {
	// In PostgreSQL, SQLSTATE 42710 corresponds to duplicate_object.
    if (e.sqlState != "42710") {
            throw e
    }
}

Тепер намір є явним. Ми не кажемо "помилки бази даних не мають значення". Ми кажемо "цей дуже конкретний збій є очікуваним і прийнятним; все інше — ні".

І це стосується не тільки таких "допоміжних" (helper) функцій — це стосується всього.

У невдачі є пункт призначення

І хоча ви зазвичай не хочете, щоб ексепшни на кшталт "розірваного з'єднання" (особливо вони) витікали за межі вашого шару бази даних (що, очевидно, може бути провиною ні нашою, ні користувача), це не означає, що кожну помилку слід ловити скрізь, де вона може виникнути.

Деякі збої очікувано трапляються, але це не означає, що їх слід обробляти скрізь, де вони виникають. Розірване з'єднання, збій транзакції або помилка драйвера — це частина середовища, в якому працює ваш код. Вони не є ані помилками програміста, ані умовами, які слід мовчки абсорбувати.

Цим збоям треба дозволити поширюватися (propagate) доти, доки вони не досягнуть кордону, який володіє рішенням про те, що робити далі. Відловлювання їх раніше не робить систему безпечнішою — це лише ховає факт, що щось пішло не так, і перекладає відповідальність на неправильне місце.

І саме тому в прикладі з createEnumTypeIgnoring ми не загортали всю функцію в широкий try/catch — сам по собі він нічого не означає і не робить. Там неконтрольованим збоям дозволено — і очікується, що вони будуть — поширюватися. Функція лише глушить специфічний, зрозумілий випадок, який є частиною її контракту (дублікат типу), і дозволяє всьому іншому вийти назовні.

Це навмисно. Обробка помилок — це не про запобігання киданню винятків; це про вибір кордону, де вони мають бути оброблені.

Відловлювання ексепшну занадто рано сплющує контекст. Відловлювання його занадто пізно спричиняє витік деталей інфраструктури. Правильне місце — це зазвичай кордон, який розуміє обидві сторони: він знає, яка операція була спробована і що клієнт може розумно зробити далі.

Саме тому ми також не "тягнемо логер" усюди. Логування, як і обробка помилок, належить кордону, який має достатньо контексту, щоб вирішити, чи є збій очікуваним шумом, деградованим станом чи реальним багом.

Коротше кажучи:

  • Деякі помилки повинні падати (throw).
  • Деякі помилки повинні подорожувати.
  • Дуже мало помилок повинні бути зловлені "про всяк випадок".

Дисципліна не в уникненні ексепшнів, а в тому, щоб дозволити їм рухатися, поки вони не досягнуть кордону, який може надати їм сенсу.

Бонус

До речі, ви все ще пам'ятаєте Deferred<T>.getCompletionExceptionOrNull(): T?? Якщо ви припустили, що вона повертає completion exception або null в іншому випадку — ви помилялися. Я теж. Поки воно не кинуло ексепшн в реальному коді.

Тільки після того, як ловиш баг, зазвичай опиняєшся в документації, де сказано:

Returns completion exception result if this deferred was cancelled and has completed, null if it had completed normally, or throws IllegalStateException if this deferred value has not completed yet.

This function is designed to be used from invokeOnCompletion handlers, when there is an absolute certainty that the value is already complete. See also getCompleted.

Note: This is an experimental api. This function may be removed or renamed in the future.

Це не неправильне використання — це погано спроєктоване API. Назва, тип повернення і загальні конвенції рішуче натякають на безпечний запит (safe query), проте функція ховає пастку контролю потоку (control-flow trap) за собою.

Урок не в тому, щоб "читати доки уважніше". Урок такий: не проєктуйте API, які порушують усталені очікування — особливо щодо помилок.

Урок для мейнтейнерів kotlinx.coroutines важкий: що є більш критичним — маркування функції як OrNull просто тому, що тип повернення є nullable (ніби ми не знали б цього з компілятора..?), чи попередження розробника про прихований IllegalStateException? Фокусуючись на null, API затуманює небезпеку. У чесній системі воно має кілька опцій:

  • воно не повинно кидати виняток,
  • маркер OrNull повинен бути замінений на OrThrow. Це мало б сенс, якби у нього був якийсь аналог (counterpart)
  • як мінімум, воно не повинно брехати про свою безпеку, маючи OrNull.

Фінальні думки

OrThrow, OrElse та try* — це не про те, як продукуються невдачі, а про те, як стани невдачі споживаються.

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

Для довірених кордонів — таких як системний код або дані, що надходять з бази даних, які вже були провалідовані — exception-first часто є більш ніж прийнятним вибором. У цих точках невдача зазвичай вказує на баг, а не на ситуацію, яку можна виправити, і кидання винятку комунікує це чітко.

Для недовірених або неоднозначних кордонів — як-от ввід користувача або зовнішні системи, які ми не контролюємо — safe-first API є більш чесними. Фокусування на поверненні type-safe-first результату робить невдачу явною і змушує клієнта визнати її там, де ввід є неочікуваним. Небезпечні варіанти, такі як OrThrow, все ще можуть існувати, але вони повинні бути вторинними і навмисно opt-in.

Найважливіше, OrThrow не повинен використовуватися як маркер того, що "ця функція може впасти". Його мета — запропонувати альтернативний спосіб спожити стан невдачі, а не лише анотувати небезпеку. Коли ним зловживають, він втрачає свою сигнальну цінність і стає шумом.

Ці патерни — це інструменти, а не правила. Вони працюють найкраще, коли обрані свідомо, базуючись на контракті, який ви визначаєте, кордоні, який ви перетинаєте, і тому, хто володіє невдачею. Використовуйте їх вдумливо — не за звичкою, а за наміром.

Нарешті, пам'ятайте про ціну хибної безпеки: сліпе глушіння помилок або надмірний захист кожної операції може приховати реальні проблеми. Обробляйте невдачі свідомо, на кордонах, які ви розумієте, і прокидайте (propagate) ексепшни туди, де їм місце — не скрізь, не ніде.