Ошибки как значения в Go (Errors and wrapping)
Ошибка в Go - обычное значение, которое можно обернуть контекстом, проверить по смыслу и сложить с другими: так импорт прайса сообщает обо всех плохих строках сразу, а вызывающий код решает, что делать, без разбора текста.
Проблема
Импорт прайса из CSV: строка sku;price, тысячи строк. Типичная первая версия:
for i, raw := range lines {
sku, price, _ := strings.Cut(raw, ";")
cents, err := strconv.Atoi(price)
if err != nil {
return nil, errors.New("bad price") // which line? which value?
}
// ...
}
if err != nil && strings.Contains(err.Error(), "bad price") { // matching on textИмпорт падает на первой ошибке, и менеджер правит файл по одной строке за загрузку. В сообщении нет ни номера строки, ни значения, а исходная ошибка strconv потеряна. Вызывающий код отличает "плохой файл" от "плохой строки" сравнением текста, и любая правка формулировки ломает обработку.
Решение
Go не прячет ошибки в исключения, поэтому ими можно управлять как данными:
- Сентинелы
ErrBadPrice,ErrEmptySKU,ErrDuplicateSKU,ErrBadHeader- именованные значения ошибок. С ними сравнивают черезerrors.Is, а не по тексту. - Свой тип
RowErrorнесёт номер строки и поле, а методUnwrap()отдаёт причину. Достают его черезerrors.As. %wвfmt.Errorfдобавляет контекст и сохраняет цепочку. С Go 1.20 глаголов%wможет быть несколько:ParseCentsоборачивает иErrBadPrice, и исходную*strconv.NumError, так что сработают обе проверки.errors.Joinскладывает ошибки всех строк в одну.Importвозвращает хорошие строки и общую ошибку;errors.Isпо ней находит любую из вложенных, аReportразворачивает её в список сообщений.
Структура
Две разные политики в одной функции: ошибка заголовка останавливает импорт сразу (файл не тот, строки читать бессмысленно), ошибки строк копятся, и хорошие строки всё равно возвращаются. Цепочка всегда одна: RowError -> обёртка fmt.Errorf -> сентинел и, если был, strconv.NumError.
Как это собирается
Обработчик загрузки получает файл, режет его на строки и вызывает Import. Дальше решение принимает он, а не импорт: errors.Is(err, ErrBadHeader) - отказать с "это не прайс", ошибки строк - сохранить хорошие строки и показать Report(err) списком, errors.Is(err, ErrDuplicateSKU) - добавить подсказку. Тексты ошибок нигде не сравниваются, поэтому их можно переписывать свободно.
Сценарий целиком с выводом: пример на Go.
Как это в других языках
PHP. Ошибки - исключения, и вместо сентинелов - иерархия классов: final class BadPrice extends ImportError. Контекст добавляют новым исключением с previous: new RowFailed("line 3", previous: $e), а цепочку читают через getPrevious(). Аналога errors.Join нет: собирают массив ошибок руками или бросают одно исключение со списком внутри.
Python. Тоже исключения: raise RowError(line=3) from err сохраняет причину в __cause__. С 3.11 есть прямой аналог errors.Join - ExceptionGroup, а except* BadPrice ловит нужные ошибки из группы, остальные летят дальше.
TypeScript. new Error("line 3", { cause: err }) хранит причину, AggregateError складывает несколько ошибок. Но catch (e) даёт unknown, и проверять тип приходится руками через instanceof. Поэтому для ожидаемых отказов вроде плохой строки часто берут Result-тип, как в теме о размеченных объединениях.
Запуск: make test T=languages/go/01-errors-and-wrapping
Тесты проверяют таблицу цен (хорошие и плохие значения сверяются по сентинелу), то, что RowError достаётся через errors.As вместе с номером строки и strconv.NumError, что Import собирает все ошибки строк в одну и что ошибка заголовка не похожа на ошибку строки.
Плюсы и минусы
- ✅ Видно, какие вызовы могут упасть: ошибка в сигнатуре, а не в документации.
- ✅ Проверки по смыслу (
errors.Is,errors.As) переживают любую правку текста и любое число обёрток. - ✅
errors.Joinпревращает "упали на первой" в "вот все проблемы файла" без своего типа-коллекции. - ❌
if err != nilпосле каждого вызова, и контекст в обёртку надо дописывать руками. - ❌ Сентинел - экспортируемая переменная пакета, часть публичного API: переименовать её - сломать клиентов.
- ❌ Из
errors.Joinошибки достаются через приведение кinterface{ Unwrap() []error }, удобного метода в stdlib нет.
Когда не применять
- Ошибка, которую вызывающий код не будет различать: хватит
fmt.Errorf("...: %w", err)без своего сентинела. - Свой тип ошибки без полей: если в нём только текст, это сентинел, а не тип.
- Ошибки, после которых продолжать нельзя (битый конфиг при старте): собирать их в
Joinнезачем, вернуть первую. - Программная ошибка (nil-указатель, выход за массив): это
panic, а не значение для обработки.
Связанные темы
- Цепочка обязанностей (Chain of Responsibility) - звенья проверки, каждое со своим отказом
- Спецификация (Specification) - правила проверки строк как объекты
- Размеченные объединения в TypeScript - ожидаемые отказы как значения, только через типы