Как в Swift спроектировать тип ошибки, чтобы вызывающий код надёжно различал причины сбоя без разбора текст...

Как в Swift спроектировать тип ошибки, чтобы вызывающий код надёжно различал причины сбоя без разбора текста сообщения?

Проходите собеседования с ИИ помощником Hintsage

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

Используйте отдельный тип, соответствующий Error, обычно перечисление с вариантами, отражающими существенные причины сбоя. Дополнительные данные передавайте через ассоциированные значения, а не встраивайте в текст сообщения.

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

Исторический контекст

Механизм throws отделяет обычный результат функции от нештатного завершения. Для этого ошибке нужен переносимый программный контракт, а не строка, предназначенная для чтения человеком.

Протокол Error задаёт общий механизм передачи ошибок, но не ограничивает разработчика конкретными причинами. Перечисления с ассоциированными значениями позволяют поверх этого общего механизма описать конечный набор доменных состояний.

Постановка проблемы

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

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

Подробное решение

Создайте тип ошибки на уровне того слоя, который отвечает за смысл сбоя. Для конечного набора причин подходит enum; ассоциированные значения хранят технические детали или данные, необходимые для принятия решения.

import Foundation enum ServiceError: Error { case unauthorized case transport(underlying: Error) } func load() throws -> Data { do { throw URLError(.timedOut) } catch { throw ServiceError.transport(underlying: error) } } do { _ = try load() } catch ServiceError.unauthorized { print("Нужна авторизация") } catch ServiceError.transport { print("Можно повторить запрос") } catch { print("Неизвестная ошибка") }

Внешний слой проверяет варианты ошибки, а не их описание. Низкоуровневую причину можно сохранить в underlying, но не следует делать её единственным публичным контрактом: библиотечная ошибка может измениться.

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

Result подходит, когда ошибка должна быть обычным значением: например, её нужно сохранить, передать через очередь или обработать без исключительного потока управления. Однако Result не заменяет моделирование ошибки — его параметр Failure всё равно должен быть структурированным типом, часто тем же перечислением, которое используется с throws.

Не стоит создавать вариант для каждой мелкой технической детали, если вызывающий код не может или не должен на неё реагировать. Слишком подробный публичный тип связывает потребителей с внутренней реализацией; слишком общий тип лишает их возможности выбрать правильное действие.

Ситуация из практики

Сервис загрузки профиля должен отличать отсутствие авторизации, временный сбой сети и повреждённый ответ. Вариант с текстовыми сообщениями прост, но требует хрупкого анализа строк и не даёт стабильного контракта.

Можно пробросить исходные сетевые ошибки напрямую. Это сохраняет максимум деталей, но связывает UI и бизнес-логику с конкретным сетевым стеком. Можно также свести всё к одной общей ошибке, что упрощает API, но не позволяет решить, когда безопасно повторить запрос.

Выбранный вариант — публичное перечисление уровня сервиса: unauthorized, transport и ошибка некорректного ответа с нужными данными. Внутри transport сохраняется исходная ошибка для журналирования, а вызывающий код принимает решения только по стабильным вариантам сервиса.

В результате замена HTTP-библиотеки не меняет логику экрана, повтор выполняется только для временных сбоев, а пользовательские сообщения локализуются независимо от технических деталей.

Что кандидаты часто упускают

  1. Должен ли каждый тип ошибки быть перечислением?

Нет. enum удобен для конечного набора причин, но для расширяемой иерархии или передачи уже существующего типа можно использовать структуру или отдельный тип, соответствующий Error. Важно не синтаксическое средство, а стабильный контракт: вызывающий код должен знать, какие свойства ошибки разрешено использовать.

  1. Нужно ли сравнивать ошибки напрямую через Equatable?

Только если такое сравнение действительно является частью контракта. Соответствие Error само по себе не делает тип Equatable, а ошибки с ассоциированным системным Error часто нельзя корректно сравнить структурно. Обычно надёжнее сопоставлять вариант ошибки и проверять только те данные, которые имеют бизнес-смысл.

  1. Следует ли всегда оборачивать исходную ошибку в доменный тип?

Нет, но на границе слоя это часто полезно. Обёртка сохраняет стабильную семантику для вызывающего кода и при необходимости хранит исходную ошибку для диагностики. Если же слой просто механически оборачивает каждую ошибку без добавления смысла, он усложняет обработку и скрывает полезную информацию; в таком случае лучше либо пробросить ошибку, либо явно преобразовать её в доменную причину.