Зачем это вообще нужно
Любая программа рано или поздно сталкивается с тем, что что-то пошло не так: сеть недоступна, файл не найден, пользователь ввёл некорректные данные. Вопрос не в том, случится ли ошибка, а в том, как ты на неё отреагируешь.
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 ₽) мы закладываем правильную обработку ошибок с самого начала. Переписывать её в середине проекта — это вдвое дороже и вдвое дольше, чем сделать правильно сразу.