JavaRush /Курсы /Go SELF /Typed errors в Go: структура ошибки,

Typed errors в Go: структура ошибки, Error() и Unwrap()

Go SELF
25 уровень , 3 лекция
Открыта

1. Зачем нужны typed errors, если есть errors.New и fmt.Errorf

Если вы только привыкаете к Go, то вполне естественно думать так: «Ну ошибка же — это текст. Зачем городить структуру, если можно написать fmt.Errorf("неверный id") и пойти пить чай?». Эта мысль логична — до тех пор, пока приложение не начинает жить чуть дольше одного вечера и пока обработка ошибок не становится частью UX (сообщения пользователю) и частью логики (разные реакции на разные причины).

Проблема «ошибка как строка» в том, что строка плохо подходит для принятия решений. Если вы хотите отличать «не найдено» от «невалидно» от «проблема чтения файла», то сравнивать строки — это как определять породу собаки по её лаю: иногда работает, но соседям уже страшно.

Typed error решает это очень прямолинейно: ошибка остаётся значением, но становится структурированным значением.

Небольшая таблица, чтобы зафиксировать разницу:

Подход Пример Плюсы Минусы
«Только текст»
fmt.Errorf("invalid id")
быстро, просто сложно различать причины, хочется парсить строки
Typed error (struct)
InvalidIDError{Input: raw}
можно хранить контекст в полях, удобно ветвиться по типу чуть больше кода, нужно продумать дизайн
Обёртка с причиной
&OpError{Op: "load", Err: err}
добавляет контекст операции и сохраняет причину надо решить, можно ли «раскрывать» причину наружу

Что такое 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, а мы должны:

  1. распарсить ID,
  2. найти задачу,
  3. отметить выполненной.

Ошибки тут бывают трёх разных «классов» по смыслу:

  • ввод невалидный (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 — так код будет меньше и яснее.

1
Задача
Go SELF, 25 уровень, 3 лекция
Недоступна
Проверка коридора
Проверка коридора
1
Задача
Go SELF, 25 уровень, 3 лекция
Недоступна
Деление с контекстом
Деление с контекстом
1
Задача
Go SELF, 25 уровень, 3 лекция
Недоступна
Поиск в базе
Поиск в базе
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ