1. Зачем нужны typed errors, если есть errors.New и fmt.Errorf
Если вы только привыкаете к Go, то вполне естественно думать так: «Ну ошибка же — это текст. Зачем городить структуру, если можно написать fmt.Errorf("неверный id") и пойти пить чай?». Эта мысль логична — до тех пор, пока приложение не начинает жить чуть дольше одного вечера и пока обработка ошибок не становится частью UX (сообщения пользователю) и частью логики (разные реакции на разные причины).
Проблема «ошибка как строка» в том, что строка плохо подходит для принятия решений. Если вы хотите отличать «не найдено» от «невалидно» от «проблема чтения файла», то сравнивать строки — это как определять породу собаки по её лаю: иногда работает, но соседям уже страшно.
Typed error решает это очень прямолинейно: ошибка остаётся значением, но становится структурированным значением.
Небольшая таблица, чтобы зафиксировать разницу:
| Подход | Пример | Плюсы | Минусы |
|---|---|---|---|
| «Только текст» | |
быстро, просто | сложно различать причины, хочется парсить строки |
| Typed error (struct) | |
можно хранить контекст в полях, удобно ветвиться по типу | чуть больше кода, нужно продумать дизайн |
| Обёртка с причиной | |
добавляет контекст операции и сохраняет причину | надо решить, можно ли «раскрывать» причину наружу |
Что такое typed error
Typed error — это обычная структура (struct), у которой есть метод Error() string. Никакой магии, никакого наследования, никаких специальных ключевых слов. В Go «быть ошибкой» означает лишь одно: уметь рассказать о себе строкой.
Начнём с простого примера. Представим, что мы продолжаем наше учебное приложение (условный CLI “tasker”), где есть операции со списком задач. Например, пользователь вводит ID задачи текстом, а нам нужно валидировать ввод.
package tasker
import "fmt"
type InvalidIDError struct {
Input string // что пользователь ввёл
}
func (e InvalidIDError) Error() string {
return fmt.Sprintf("invalid id %q", e.Input)
}
Здесь важно заметить две вещи.
Первая: контекст теперь не только в тексте. У нас есть поле Input, и оно доступно коду — не человеку, а именно коду. Это значит, что позже можно, например, показать пользователю одно сообщение, а в лог записать другое, или подсветить конкретное поле.
Вторая: мы сознательно делаем Error() коротким и «в стиле Go»: без точки в конце и без “Error:”. Это не формальность, а привычка, которая делает сообщения единообразными.
Теперь такая ошибка используется как обычный error:
package tasker
import "strconv"
func ParseID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
return 0, InvalidIDError{Input: s}
}
return id, nil
}
2. Проектирование typed errors: Error() и поля
Error() — для человека, поля — для программы
Когда вы начинаете писать typed errors, очень хочется запихнуть в Error() всё подряд: “операция такая-то, пользователь такой-то, id такой-то, stack trace, луна в козероге”. Но хороший Error() — это обычно компактная строка, которую приятно увидеть в консоли. Всё остальное лучше хранить в полях.
Почему? Потому что Error() — это интерфейсный контракт «дай строку». Он не обещает стабильный формат для машинного разбора. И, что ещё важнее, человек может захотеть увидеть одно, а программа — принять решение на основании другого.
В Go прямо подчёркивается, что ответственность ошибки — суммировать контекст, чтобы сообщение было полезным (например, os.Open сообщает “open …: permission denied”, а не просто “permission denied”). Typed errors просто делают следующий шаг: мы суммируем контекст в Error(), но сохраняем детали в полях.
Пример «ошибка не найдена задача» в нашем tasker:
package tasker
import "fmt"
type TaskNotFoundError struct {
ID int
}
func (e TaskNotFoundError) Error() string {
return fmt.Sprintf("task %d not found", e.ID)
}
Пока всё выглядит почти как «обычный текст», но сила — в том, что ID доступен отдельно.
Typed errors для валидации и доменных правил
Давайте сделаем кусочек более «похожий на жизнь». У нас есть задача (Task) и команда “mark done”. Пользователь передаёт ID, а мы должны:
- распарсить ID,
- найти задачу,
- отметить выполненной.
Ошибки тут бывают трёх разных «классов» по смыслу:
- ввод невалидный (validation),
- задача не найдена (not found),
- что-то сломалось внутри (internal / I/O и т.п.).
Мы сейчас не строим «таксономию ошибок приложения целиком», но даже на этом маленьком примере видно, почему typed errors полезны.
Сделаем три типа:
package tasker
type ValidationError struct {
Field string
Msg string
}
func (e ValidationError) Error() string {
return "invalid " + e.Field + ": " + e.Msg
}
package tasker
import "fmt"
type TaskNotFoundError struct {
ID int
}
func (e TaskNotFoundError) Error() string {
return fmt.Sprintf("task %d not found", e.ID)
}
package tasker
type OpError struct {
Op string
Err error
}
func (e *OpError) Error() string {
return e.Op + ": " + e.Err.Error()
}
Первые два — чистые typed errors “про предметную область”. Третий — обёртка операции: «мы делали X, и там случилось Y».
3. Value receiver и pointer receiver в Error()
Сейчас будет кусочек, где Go проявляет свою «любовь к точности». Вы можете написать Error() как метод значения (func (e T) Error() string) или как метод указателя (func (e *T) Error() string). Оба варианта возможны, но они ведут себя по-разному.
Смысл простой: если Error() определён на *T, то только *T реализует интерфейс error, а T — нет. Это не «занудство компилятора», это прямое следствие набора методов (method set), который вы уже трогали раньше.
Мини-демонстрация:
package main
import "fmt"
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func main() {
// var err error = MyError{} // не скомпилируется
var err error = &MyError{} // ok
fmt.Println(err) // boom
}
Практическое правило для новичка звучит так: если ошибка маленькая, неизменяемая и не содержит «внутренней причины», удобно делать Error() на value receiver и возвращать значение. Если ошибка содержит вложенный Err error, или если вы хотите избежать копирования (редко актуально для ошибок, но бывает), можно использовать pointer receiver — но тогда внимательно возвращайте именно указатель.
4. Обёртки ошибок и Unwrap()
Когда ошибки поднимаются вверх по стеку, мы почти всегда хотим добавлять контекст. Самый простой вариант — fmt.Errorf("что делали: %w", err), который даёт wrapping и делает причину доступной для errors.Is/errors.As.
Но иногда удобнее иметь свою обёртку с полями (например, Op, ID, Filename) — и вот тут появляется Unwrap().
В Go 1.13 закреплена конвенция: если ошибка содержит другую ошибку и вы хотите, чтобы внешний код мог добраться до причины, реализуйте метод Unwrap() error, который возвращает вложенную ошибку. Это настолько стандартно, что вокруг этого построены errors.Is и errors.As.
Доработаем наш OpError:
package tasker
type OpError struct {
Op string
Err error
}
func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }
Теперь у нас появляется «цепочка ошибок»: внешний слой говорит “какая операция”, внутренний — “что конкретно пошло не так”.
Очень важно понимать философию решения: реализовать Unwrap() — это почти как «экспортировать поле»: вы делаете причину частью контракта. Иногда это правильно, иногда — нет. В материалах про ошибки в Go отдельно подчёркивается, что wrapping делает внутреннюю ошибку наблюдаемой для кода, а значит вы как будто обещаете «сохранять» этот тип ошибки в будущем.
Чтобы зафиксировать механику, вот маленькая схема “слоёного пирога” ошибок:
flowchart TD
A[CLI handler: команда done] -->|вызывает| B[app.MarkDone]
B -->|вызывает| C[storage.FindByID]
C -->|возвращает| E1[TaskNotFoundError]
B -->|оборачивает| E2["OpError{Op:'mark done', Err:E1}"]
A -->|печатает err| OUT[stderr/stdout]
5. Пример: оборачиваем ошибки в tasker
Теперь соберём мини-фрагмент “от обработчика команды до хранилища”. Без лишней архитектуры, просто чтобы увидеть typed errors в движении.
Предположим, у нас есть интерфейс хранилища (мы его не обсуждаем глубоко, нам важны ошибки):
package tasker
type Storage interface {
MarkDone(id int) error
}
Функция уровня приложения:
package tasker
func MarkDone(s Storage, rawID string) error {
id, err := ParseID(rawID)
if err != nil {
return &OpError{Op: "parse id", Err: err}
}
if err := s.MarkDone(id); err != nil {
return &OpError{Op: "mark done", Err: err}
}
return nil
}
Обратите внимание на «приятность» чтения: каждая обёртка добавляет смысл “где мы были”, а причина сохраняется.
Хранилище может вернуть доменную ошибку, если задачи нет:
package tasker
type InMemoryStorage struct {
done map[int]bool
}
func (s *InMemoryStorage) MarkDone(id int) error {
if _, ok := s.done[id]; !ok {
return TaskNotFoundError{ID: id}
}
s.done[id] = true
return nil
}
Теперь снаружи вы печатаете err и видите что-то вроде:
- mark done: task 10 not found
А внутри (в цепочке) всё ещё есть конкретный тип TaskNotFoundError, и его можно различать от ValidationError. Это нам пригодится для аккуратных пользовательских сообщений (но сам механизм «доставания» из цепочки мы в этой лекции намеренно не углубляем).
Почему это лучше, чем сравнивать строки
Чтобы мозг не воспринимал typed errors как «просто ещё один стиль», давайте честно сравним две реальности.
В реальности №1 вы возвращаете fmt.Errorf("task not found"). В обработчике команды вам нужно понять: это “не найдено” или “сломалось”? И вы начинаете делать что-то вроде strings.Contains(err.Error(), "not found"). Это выглядит как программирование, но по факту вы строите дом из мокрого картона: поменяли текст — сломали логику.
В реальности №2 у вас TaskNotFoundError{ID: ...}. Текст сообщения может поменяться, но тип останется типом, а поле ID останется ID. Именно поэтому Go-сообщество так активно использует ошибки как значения и как структуры, а не только как строки.
Кстати, в стандартной библиотеке можно увидеть тот же подход вживую: ошибки сетевых операций — это структуры, которые содержат другие ошибки, и иногда код “разбирает” их по слоям, чтобы понять настоящую причину. Это не «сложность ради сложности», а способ сделать обработку ошибок управляемой.
6. Типичные ошибки при проектировании typed errors
Ошибка №1: хранить весь контекст только в Error(), а потом пытаться парсить строку.
Это распространённая ловушка: вы вроде бы сделали typed error, но всё равно засунули детали в текст и потом сравниваете строки или ищете подстроки. Если ошибка должна нести данные для логики, эти данные должны жить в полях (ID, Field, Op, Filename), а Error() должен быть человеческим резюме.
Ошибка №2: выбрать pointer receiver для Error(), а возвращать значение, а не указатель.
Когда Error() объявлен на *T, значение T{...} не реализует error. В лучшем случае вы это поймаете компилятором сразу, в худшем — начнёте «лечить» не то место. Если уж сделали func (e *MyErr) Error() string, возвращайте &MyErr{...}.
Ошибка №3: делать Error() слишком длинным и нестабильным.
Иногда хочется сразу записать туда пол-лога: JSON, поля, подсказки, “позвоните администратору”. В результате ошибка начинает жить своей жизнью: где-то печатается пользователю, где-то попадает в тесты, где-то сравнивается как строка. Гораздо спокойнее держать Error() коротким, а подробности отдавать через поля и/или через отдельный рендеринг сообщения на границе приложения.
Ошибка №4: добавлять Unwrap() автоматически.
Unwrap() делает внутреннюю ошибку наблюдаемой снаружи: это почти API-обещание. Если причина — деталь реализации, и вы не хотите, чтобы внешний код зависел от неё, лучше не раскрывать её через Unwrap() (или оборачивать иначе). В материалах про ошибки в Go отдельно подчёркивается, что решение “wrap или не wrap” влияет на контракт вашего кода.
Ошибка №5: плодить typed errors на каждый чих без причины.
Typed error оправдан, когда он помогает принять решение или выдать правильный UX: подсветить поле, отличить not-found от validation, показать понятное сообщение. Если ошибка никогда не анализируется и не несёт полезных данных, проще и честнее оставить fmt.Errorf/errors.New — так код будет меньше и яснее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ