Зачем это вообще нужно

Любая программа рано или поздно сталкивается с тем, что что-то пошло не так: сеть недоступна, файл не найден, пользователь ввёл некорректные данные. Вопрос не в том, случится ли ошибка, а в том, как ты на неё отреагируешь.

Swift предлагает несколько механизмов для работы с ошибками: throws/try/catch, тип Result и специальный тип Never. Каждый решает свою задачу — и зная разницу, ты будешь писать код, который не падает в самый неподходящий момент.

throws — классический механизм

Самый распространённый способ сигнализировать об ошибке — пометить функцию ключевым словом throws. Это означает: функция может бросить ошибку, и вызывающий код обязан с ней что-то сделать.

enum NetworkError: Error {
    case noConnection
    case timeout
    case invalidResponse
}

func fetchUser(id: Int) throws -> User {
    guard hasConnection else {
        throw NetworkError.noConnection
    }
    // ... логика запроса
    return user
}

При вызове такой функции нужно использовать try:

do {
    let user = try fetchUser(id: 42)
    print(user.name)
} catch NetworkError.noConnection {
    print("Нет соединения")
} catch NetworkError.timeout {
    print("Сервер не ответил")
} catch {
    print("Неизвестная ошибка: \(error)")
}

try, try? и try!

Swift даёт три варианта вызова бросающей функции:

  • try — стандартный, внутри блока do-catch
  • try? — превращает результат в Optional. Если ошибка — получишь nil
  • try! — крашит приложение при ошибке. Используй только если абсолютно уверен, что ошибки не будет
// try? — безопасно, но теряем информацию об ошибке
let user = try? fetchUser(id: 42)

// try! — опасно, но иногда оправдано (например, загрузка из бандла)
let config = try! loadBundledConfig()

try? удобен, когда ошибка тебя не интересует — только результат. Но если нужно понять, что именно пошло не так, лучше использовать полный do-catch.

Отдельно про try!: в продакшен-коде это почти всегда плохая идея. Единственный обоснованный кейс — загрузка ресурсов из основного бандла, когда их отсутствие означает сломанную сборку. Для всего остального — слишком рискованно.

Result — явный тип для успеха и ошибки

Result<Success, Failure> появился в Swift 5 и стал стандартной альтернативой колбэкам с парой (Data?, Error?). Вместо двух параметров, один из которых всегда nil, ты получаешь одно значение: либо данные, либо ошибка.

func fetchUser(id: Int, completion: @escaping (Result<User, NetworkError>) -> Void) {
    guard hasConnection else {
        completion(.failure(.noConnection))
        return
    }
    // ... запрос
    completion(.success(user))
}

Использование:

fetchUser(id: 42) { result in
    switch result {
    case .success(let user):
        print(user.name)
    case .failure(let error):
        print("Ошибка: \(error)")
    }
}

Удобные методы Result

У Result есть несколько полезных методов, которые позволяют трансформировать значения без распаковки:

// map — трансформирует успешное значение
let nameResult = userResult.map { $0.name }

// flatMap — для операций, которые тоже могут вернуть Result
let profileResult = userResult.flatMap { user in
    loadProfile(for: user)
}

// mapError — трансформирует ошибку
let mapped = userResult.mapError { AppError.network($0) }

// get() — бросает ошибку или возвращает значение
let user = try userResult.get()

Метод get() особенно удобен: он позволяет соединить Result с throws-кодом без ручного switch:

do {
    let user = try userResult.get()
    updateUI(with: user)
} catch {
    showError(error)
}

Result vs throws — что выбрать

Короткий ответ:

  • throws — для синхронного кода и async/await
  • Result — для асинхронных колбэков или когда нужно сохранить результат и передать его позже

С появлением async/await в Swift 5.5 большинство нового кода пишется с throws:

func fetchUser(id: Int) async throws -> User {
    let data = try await networkClient.get("/users/\(id)")
    return try JSONDecoder().decode(User.self, from: data)
}

Result остался актуальным для совместимости со старым кодом и для случаев, когда нужно кешировать не только данные, но и ошибку. Например, запомнить, что предыдущая попытка загрузки провалилась, и не повторять её без необходимости — это типичная задача для Result.

Never — функция, которая никогда не возвращает

Never — специальный тип в Swift. Функция с возвращаемым типом Never гарантирует компилятору, что она никогда не вернёт управление нормальным образом. Это либо краш, либо бесконечный цикл.

Стандартные примеры из стандартной библиотеки:

func fatalError(_ message: String) -> Never
func preconditionFailure(_ message: String) -> Never

На практике Never полезен, когда нужно обозначить логически недостижимые ветки кода:

func processPayment(method: PaymentMethod) -> Receipt {
    switch method {
    case .card(let number):
        return processCard(number)
    case .cash:
        return processCash()
    case .crypto:
        fatalError("Крипта не поддерживается в этой версии приложения")
    }
}

Компилятор понимает, что после fatalError выполнение не продолжится, и не требует возвращаемого значения из этой ветки. Без Never пришлось бы возвращать какой-нибудь заглушечный Receipt или делать preconditionFailure с кастом.

Never в дженериках

Never — это bottom type. Он конформит любому протоколу, что открывает интересные возможности в дженериках. Классический пример — Result<String, Never>:

// Результат, который никогда не может быть ошибкой
let result: Result<String, Never> = .success("Всё ок")

if case .success(let value) = result {
    print(value) // не нужно обрабатывать .failure
}

Полезно, когда API требует Result, но конкретная операция заведомо безопасна. Вместо Result<String, Error> с пустым кейсом ошибки — явный Never, который компилятор понимает правильно.

Как проектировать ошибки правильно

Enum с ассоциированными значениями

Базовый приём — использовать enum вместо строк или числовых кодов:

enum APIError: Error {
    case unauthorized
    case notFound(path: String)
    case serverError(code: Int)
    case decodingFailed(Error)
}

Ассоциированные значения дают контекст. Вместо просто .networkError у тебя будет .notFound(path: "/users/42") — сразу понятно, что случилось, без разбора строк.

Разделяй ошибки по слоям

Не смешивай сетевые ошибки, ошибки бизнес-логики и UI в одном типе. Хорошая архитектура работает послойно:

// Сетевой слой
enum NetworkError: Error {
    case noConnection
    case timeout
    case httpError(Int)
}

// Слой репозитория
enum RepositoryError: Error {
    case userNotFound
    case network(NetworkError)
}

// Локализация для пользователя
extension RepositoryError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .userNotFound:
            return "Пользователь не найден"
        case .network:
            return "Проблема с сетью. Проверьте интернет-соединение"
        }
    }
}

Реализуй LocalizedError

Если ошибка когда-либо покажется пользователю — реализуй LocalizedError. Это позволит передавать понятное сообщение напрямую во вью без дополнительных маппингов в каждом контроллере.

Не проглатывай ошибки

Один из самых опасных антипаттернов — try? без логирования:

// Плохо — теряем всю информацию о том, что пошло не так
let user = try? fetchUser(id: 42)

// Лучше
do {
    let user = try fetchUser(id: 42)
    updateUI(with: user)
} catch {
    logger.error("Не удалось загрузить пользователя: \(error)")
    showErrorBanner()
}

Молчащие ошибки — это баги, которые ты найдёшь только после жалобы пользователя.

Обработка ошибок с async/await

С async/await код стал значительно линейнее. Сравни старый стиль с новым:

До:

fetchUser(id: 42) { result in
    switch result {
    case .success(let user):
        fetchOrders(for: user) { result in
            switch result {
            case .success(let orders):
                DispatchQueue.main.async {
                    self.updateUI(user: user, orders: orders)
                }
            case .failure(let error):
                self.handleError(error)
            }
        }
    case .failure(let error):
        self.handleError(error)
    }
}

После:

Task {
    do {
        let user = try await fetchUser(id: 42)
        let orders = try await fetchOrders(for: user)
        await updateUI(user: user, orders: orders)
    } catch {
        handleError(error)
    }
}

Ошибка обрабатывается один раз — вместо дублирования на каждом уровне вложенности. Код читается как синхронный, хотя по факту асинхронный.

Параллельные задачи и ошибки

При использовании withThrowingTaskGroup ошибка в любой задаче отменяет всю группу:

let users = try await withThrowingTaskGroup(of: User.self) { group in
    for id in userIds {
        group.addTask { try await fetchUser(id: id) }
    }
    var result: [User] = []
    for try await user in group {
        result.append(user)
    }
    return result
}

Если нужно продолжать работу даже при частичных ошибках — оберни задачи в Result:

group.addTask {
    do {
        return .success(try await fetchUser(id: id))
    } catch {
        return .failure(error)
    }
}

Тогда ты сам решаешь, что делать с каждой ошибкой, а не отдаёшь контроль рантайму.

Краткий чек-лист

  • Синхронный код — throws
  • Асинхронные колбэки — Result
  • Новый асинхронный код — async throws
  • Недостижимые ветки — fatalError с возвратом Never
  • Типы ошибок — enum с ассоциированными значениями
  • Разделяй ошибки по слоям архитектуры
  • Реализуй LocalizedError для ошибок, которые видит пользователь
  • Не используй try! в продакшен-коде без уверенности
  • Не проглатывай ошибки — логируй или показывай пользователю

В REEXY при разработке мобильных приложений и Telegram-ботов (от 1 500 ₽) мы закладываем правильную обработку ошибок с самого начала. Переписывать её в середине проекта — это вдвое дороже и вдвое дольше, чем сделать правильно сразу.