Error Propagation: що це, механізм поширення помилок і як працює в мобільній розробці

Автор: IT Sectr Опубліковано: 2026-05-26 Час читання: 9 хв

Error Propagation — це механізм поширення помилки вгору по стеку викликів від місця виникнення до обробника. Коли функція не може обробити помилку самостійно, вона передає її стороні, що викликає, через виняток (exception), throws-декларацію або Return Type. Правильна реалізація propagation критична для стабільності мобільних застосунків: необроблені або неправильно передані помилки призводять до крашів. За даними Apple Swift Documentation (2026), автоматичне поширення через throws у Swift дозволяє передавати помилку на будь-який рівень без шаблонного коду.

Головне

  • Error Propagation — передача помилки від місця виникнення вгору по стеку до обробника, минаючи проміжні функції
  • Автоматичне поширення через throws у Swift передає помилку без явного коду на кожному рівні стеку
  • Ручне поширення у Kotlin та Dart вимагає явного try-catch або передачі в Result-контейнері на кожному рівні
  • Checked exceptions у Java примушують до поширення через throws у сигнатурі, unchecked — дозволяють ігнорувати
  • Result Type — альтернатива виняткам, де помилка передається як значення без розкрутки стеку

Що таке Error Propagation?

Error Propagation (поширення помилки) — це процес передачі об'єкта помилки від функції, де вона виникла, вгору по ланцюжку викликів до найближчого відповідного обробника. Уявіть стек викликів: ViewController викликає ViewModel, ViewModel викликає Repository, Repository викликає API. Якщо API повертає мережеву помилку, вона має пройти через Repository та ViewModel до ViewController, який покаже повідомлення користувачеві. Кожна проміжна функція вирішує: обробити помилку або передати далі (propagate).

Існує два підходи до поширення: автоматичний і ручний. При автоматичному підході (Swift throws, Java checked exceptions) компілятор примушує розробника або обробити помилку, або декларувати поширення в сигнатурі. При ручному підході (Result Type, Kotlin Try) помилка передається як значення — розробник явно пише код для передачі або трансформації помилки. За даними Kotlin Result Docs (2026), Result<T> у Kotlin не призначений для поширення через межі функцій безпосередньо — його потрібно трансформувати або обробляти на кожному рівні, що робить поширення більш усвідомленим, але й більш багатослівним.

Вибір підходу залежить від архітектури застосунку та мови. У Swift домінує автоматичне поширення через throws, у Kotlin — мікс винятків (для неочікуваних помилок) та Result-подібних контейнерів (для очікуваних). Важливо розуміти: поширення — це не мета, а необхідність. Ідеальна архітектура мінімізує глибину поширення, обробляючи помилки на максимально низькому рівні, де є достатньо контексту для прийняття рішення.

Поширення через Throws у Swift

У Swift поширення через throws відбувається автоматично: якщо функція A з throws викликає функцію B з throws, і A не обробляє помилку B у do-catch, помилка автоматично передається стороні, що викликає A. Це позбавляє шаблонного коду, характерного для Java checked exceptions, де throws потрібно декларувати в кожному методі ланцюжка. Swift використовує принцип «одна throws-функція в ланцюжку = весь ланцюжок стає throws, якщо не обробляти на проміжних рівнях».

swift
struct UserRepository {
    func fetchUser(id: Int) throws -> User {
        let data = try networkService.request(path: "/users/\(id)")
        return try parseUser(from: data)
    }
}

class UserViewModel {
    let repo = UserRepository()

    func loadUser(id: Int) throws -> User {
        return try repo.fetchUser(id: id)
    }
}

// ViewController — кінцевий обробник
func onButtonTap() {
    let vm = UserViewModel()
    do {
        let user = try vm.loadUser(id: 42)
        updateUI(user)
    } catch {
        showError("Не вдалося завантажити користувача")
    }
}

Ланцюжок поширення: networkService.request -> fetchUser -> loadUser -> onButtonTap. Кожна проміжна функція позначена throws і не містить do-catch — помилка автоматично передається вгору. ViewController onButtonTap — кінцевий обробник з do-catch. Якби ViewModel вирішила трансформувати помилку (загорнути в інший тип), вона могла б використати do-catch і новий throw. Автоматичне поширення скорочує код: Repository не потрібно знати, як обробляти помилку — це відповідальність ViewController, який має доступ до UI для показу повідомлення користувачеві.

Поширення через винятки у Kotlin

У Kotlin поширення через винятки не вимагає декларації throws у сигнатурі (всі винятки unchecked). Виняток автоматично піднімається по стеку, доки не зустріне try-catch. Однак відсутність throws у сигнатурі робить поширення неявним: розробник не бачить із сигнатури функції, що вона може викинути виняток. Це плюс (менше шаблонного коду) і мінус (легше забути обробити) одночасно. Kotlin вирішує цю проблему через угоди та архітектурні патерни, а не через мову.

kotlin
class UserRepository(
    private val api: ApiService,
    private val db: Database
) {
    suspend fun getUser(id: String): User {
        return try {
            api.fetchUser(id)
        } catch (e: IOException) {
            db.getCachedUser(id) ?: throw AppException("User unavailable")
        }
    }
}

class UserViewModel(private val repo: UserRepository) {
    private val _state = MutableStateFlow<UiState<User>>(UiState.Loading)
    val state: StateFlow<UiState<User>> = _state

    fun loadUser(id: String) {
        viewModelScope.launch {
            try {
                val user = repo.getUser(id)
                _state.value = UiState.Success(user)
            } catch (e: AppException) {
                _state.value = UiState.Error(e.message ?: "Unknown")
            }
        }
    }
}

У Repository поширення з трансформацією: при IOException (мережа недоступна) функція намагається отримати кешовані дані з бази даних. Якщо кеш порожній, викидає AppException — поширення продовжується вже з новим типом помилки. ViewModel перехоплює AppException і транслює в UiState.Error — помилка не йде далі, поширення завершено на рівні UI-шару. Kotlin Coroutines додають особливості: винятки в launch автоматично поширюються через CoroutineExceptionHandler, а в async — лише при виклику await(). Це важливо враховувати при проєктуванні поширення в корутинах — SupervisorJob запобігає скасуванню батьківської корутини при помилці в дочірній.

Поширення через Result Type

Альтернатива виняткам — поширення через тип-контейнер, який передає успіх або помилку як значення. У цьому підході функція повертає не значення, а обгортку: Result<T, E> у Swift, Result<T> у Kotlin, Either<L, R> у Dart (із пакета fpdart або dartz). Помилка не розкручує стек — вона просто лежить у контейнері, і наступний рівень вирішує, що з нею робити. Це робить поширення більш явним і контрольованим.

kotlin
data class HttpResult<out T>(
    val data: T?,
    val error: AppError?
) {
    val isSuccess: Boolean get() = data != null
    val isError: Boolean get() = error != null
}

sealed class AppError {
    data class Network(val message: String) : AppError()
    data class Auth(val message: String) : AppError()
}

fun fetchUser(id: String): HttpResult<User> {
    return try {
        val response = api.get("/users/$id")
        HttpResult(data = parseUser(response), error = null)
    } catch (e: IOException) {
        HttpResult(data = null, error = AppError.Network("No internet"))
    }
}

HttpResult<T> — простий контейнер із полями data та error. Sealed class AppError визначає типи помилок (Network, Auth). Функція fetchUser повертає HttpResult, поширення не вимагає розкрутки стеку — сторона, що викликає, просто перевіряє isSuccess/isError. Такий підхід особливо корисний у Clean Architecture, де кожен шар (data, domain, presentation) може трансформувати помилку: IOError -> DomainError -> UiError. Поширення через контейнер робить ці трансформації явними та тестованими, на відміну від винятків, де ланцюжок перетворень не видно в сигнатурах функцій.

Поширення vs Обробка: коли передавати, коли обробляти

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

СценарійДіяОбґрунтування
Мережева помилка в RepositoryPropagateRepository не знає, чи хоче користувач повторити запит
Помилка парсингу в RepositoryОбробити (повернути default)Repository знає формат, може повернути fallback-значення
Таймаут у ViewModelОбробити (UiState.Error)ViewModel керує UiState, знає, як транслювати помилку
Помилка авторизації в InterceptorОбробити (refresh token)Interceptor має доступ до токенів і може відновити сесію
Невідома помилка в UseCasePropagateUseCase не має UI-контексту — лише бізнес-логіка

Золоте правило: мінімум поширення, максимум обробки на нижніх рівнях. Якщо Repository може відновитися з кешу — він має це зробити, не передаючи помилку вище. Якщо ViewModel може показати Snackbar — хай покаже, не вимагаючи від ViewController додаткового коду. Кожен рівень поширення збільшує зв'язність та ускладнює тестування. За даними Google Android Architecture Guide (2026), рекомендується мінімізувати поширення через межі шарів, використовуючи sealed class UiState для представлення всіх можливих станів (Loading, Success, Error) на рівні ViewModel, і не передавати винятки в UI-шар безпосередньо.

Проблеми та антипатерни Error Propagation

Некоректне поширення — джерело важко відловлюваних багів у мобільних застосунках. Розглянемо п'ять основних проблем, з якими стикаються розробники, та способи їх вирішення.

Втрата контексту помилки

Найчастіша проблема: при поширенні виняток перехоплюється, логується та викидається новий без оригінального винятку. Розробник втрачає StackTrace і не може зрозуміти, де саме виникла помилка. У Swift використовуйте error chaining: throw MyError(context: originalError). У Kotlin: throw AppException(cause = originalException). У Dart: throw AppException(message, originalException). Ніколи не створюйте новий виняток без передачі cause/underlyingError.

Ігнорування помилки (порожній catch)

catch (e: Exception) { /* нічого */ } — антипатерн, який призводить до того, що застосунок продовжує роботу в некоректному стані. Якщо ви впевнені, що помилку можна ігнорувати — додайте коментар з обґрунтуванням. У Swift для опціонального ігнорування використовуйте try? (помилка -> nil). У Kotlin — Result<T>.onFailure { /* log */ }. Не глушіть винятки без логування.

Надмірна глибина поширення

Якщо помилка проходить через 5+ рівнів без обробки, архітектура потребує перегляду. Кожен рівень поширення — це залежність від сигнатури throws нижчележачих функцій. Рішення: використовуйте Failure-контейнери (sealed class Result { Success, Error }) на межах шарів, щоб зробити поширення явним і обмеженим. Чим коротший ланцюжок поширення, тим простіше тестувати та налагоджувати код.

Поширення в корутинах без SupervisorJob

У Kotlin Coroutines виняток у launch за замовчуванням скасовує батьківську корутину та всіх siblings (дітей того ж scope). Якщо одне з 10 паралельних завдань впало, інші 9 будуть скасовані, що часто небажано. Використовуйте SupervisorJob або supervisorScope для ізоляції помилок: помилка в одному child не скасовує siblings. ViewModelScope використовує SupervisorJob за замовчуванням, що рятує від цієї проблеми в Android.

Поширення через callback без обробки

У callback-based API помилка часто передається як параметр колбеку. Якщо callback не обробляє помилку (або обробляє некоректно), поширення стає неявним і легко втрачається. Рішення: мігруйте на async/await (Swift) або корутини (Kotlin), де поширення працює через стандартні механізми try-catch. Якщо callback неминучий — використовуйте Either<Error, T> або Result<T> для примусової обробки обох кейсів.

Часті запитання

Чим Error Propagation відрізняється від throw?

Throw — це одноразова дія викиду винятку. Error Propagation — весь процес передачі помилки через кілька рівнів стеку, від throw до catch. Поширення включає throw, автоматичну або ручну передачу через проміжні функції та фінальну обробку. Це більш широке поняття, що описує життєвий цикл помилки.

Як тестувати Error Propagation?

Використовуйте mock-об'єкти, які викидають винятки в заданих сценаріях. Перевіряйте, що функція коректно propagate або обробляє помилку через assertThrows (Kotlin/JUnit) або XCTAssertThrowsError (Swift/XCTest). Для Result-based propagation перевіряйте isSuccess/isError та значення в обох кейсах.

Коли поширення через Result краще за винятки?

Result propagation краща для очікуваних помилок (неправильні дані, бізнес-правила) в межах однієї архітектурної межі. Винятки кращі для неочікуваних помилок (втрата мережі, помилки введення-виведення), які мають бути оброблені на високому рівні. Результат з помилкою не перериває потік виконання, виняток — перериває.

Як propagate помилку через корутини Kotlin?

У Kotlin Coroutines виняток у launch автоматично propagate через CoroutineScope зі скасуванням siblings. Використовуйте supervisorScope або SupervisorJob для ізоляції: помилка в одній корутині не скасовує інші. Для async помилку потрібно явно обробити через try-catch при виклику await(), інакше вона буде проковтнута.

Який рівень має бути кінцевим обробником помилки?

Ідеальний кінцевий обробник — UI-шар (ViewController, Fragment/Composable). Тільки він має доступ до інтерфейсу користувача і може показати повідомлення, Snackbar або діалог. Проміжні шари (Repository, UseCase, ViewModel) propagate помилку, трансформуючи при потребі в більш абстрактний доменний тип.

Підсумки

  • Error Propagation — передача помилки вгору по стеку від місця виникнення до обробника через винятки або Result-контейнери
  • Автоматичне поширення у Swift через throws не потребує коду на проміжних рівнях — помилка піднімається сама
  • Ручне поширення у Kotlin через явний try-catch і throw на кожному рівні робить обробку усвідомленою, але багатослівною
  • Result Type передає помилку як значення без розкрутки стеку, зручний для очікуваних помилок у Clean Architecture
  • Правило обробки: обробляй на рівні з контекстом (UI), propagate через рівні без контексту (domain, data)
  • Антипатерни: порожній catch, втрата cause при трансформації, надмірна глибина поширення, ігнорування SupervisorJob
  • Проєктуйте типізовані помилки (sealed class / enum) для кожного шару та трансформуйте їх при перетині меж шарів

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також