Go не бросает исключений. Любая функция, способная не справиться, возвращает ошибку отдельным значением, а вызывающий проверяет её в лоб: if err != nil. Одни видят в этом многословие, от которого ноют глаза, и ностальгию по лаконичному try/catch. Другие замечают, что код впервые стал честным: каждая точка отказа названа по имени, поток управления не делает скрытых зигзагов. Разберём устройство механизма, реальные издержки многословности и приёмы, которые сокращают её без потери контроля: оборачивание через %w, сопоставление через errors.Is и errors.As, единый обработчик на весь слой и соединение ошибок через errors.Join.

Ошибка как обычное значение встроенного интерфейса error

В основе лежит крошечный интерфейс:

type error interface {
	Error() string
}

Любой тип с таким методом годится в ошибки. Простейший создаёт errors.New("not found"), форматированный собирает fmt.Errorf. Сигнатура os.Open показывает канон на все случаи:

func Open(name string) (*File, error)

Пара "результат, ошибка" работает как жёсткий договор: если err не равен nil, остальные возвращаемые значения лишены смысла и трогать их нельзя. Ошибка в Go - обычное значение, её кладут в переменную, передают в функцию, хранят в структуре, сравнивают. Исключений в языке нет, поэтому прочтя функцию сверху вниз, инженер видит все места, где возможен сбой, без невидимой механики раскрутки стека.

Мелочь, которая всё меняет: error - интерфейс, значит ошибкой бывает структура с полезными полями, а не только строка. nil означает успех, любое другое значение - сбой, третий вариант не предусмотрен.

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

Многословность проверок if err != nil и что она покупает

Типичная функция любого проекта выглядит как лестница:

func loadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}
	if err := cfg.Validate(); err != nil {
		return nil, fmt.Errorf("validate config: %w", err)
	}
	return &cfg, nil
}

Три шага - три проверки, и в большой кодовой базе таких лестниц сотни. Критики говорят про шум, про то, что содержательные строки тонут в обвязке. Спор был бы чистой эстетикой, если бы многословность ничего не покупала. Но она покупает главное: решение об ошибке принимается там, где она возникла, либо явно откладывается на уровень выше. Никакой вызов не может незаметно прервать функцию на середине. Проглотить сбой возможно только через пустой идентификатор, и запись вида result, _ := parse() видна при ревью как красная тряпка.

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

Отдельная тема - затенение переменной err. Повторное := с новой переменной в левой части работает тихо:

f, err := os.Open(path)
if err != nil {
	return fmt.Errorf("open: %w", err)
}
data, err := io.ReadAll(f)

Во второй паре строк := законен, потому что слева появилась новая переменная data, а err просто получает новое значение. Опасность кроется в другом: длинная функция с десятком таких блоков превращается в шараду, где уже непонятно, какой err проверяется. Отсюда правило стиля: держать проверку вплотную к вызову и не разрывать пару вызов-проверка посторонним кодом.

Оборачивание ошибок с контекстом и глагол формата %w

До осени 2019 года контекст добавляли глаголом %v, и старая ошибка растворялась в тексте: понять, чем лечить, было уже нельзя. С Go 1.13 у fmt.Errorf появился %w:

return fmt.Errorf("open socket %s: %w", addr, err)

Такая ошибка хранит исходный сбой внутри, метод Error() печатает всю цепочку, а глагол %w требует, чтобы операнд был значением типа error; чужой тип там ловит go vet. Сторонние ошибки упаковываются в собственный тип с методом Unwrap:

type QueryError struct {
	Query string
	Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }

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

Если исходник нужен - %w, если хватает текста - %v, если контракт важнее - новая доменная ошибка.

У %w есть оборотная сторона: обёртка становится частью интерфейса функции. Стоит позже сменить %w на %v - и вызывающий код с errors.Is сломается молча. Решение об упаковке фиксируют на ревью как контракт, а не как вкусовщину в оформлении.

Сопоставление ошибок через errors.Is и errors.As

Сторожевые значения сравнивают через errors.Is:

var ErrNotFound = errors.New("not found")

if errors.Is(err, ErrNotFound) {
	return http.StatusNotFound
}

Функция идёт по всей цепочке и возвращает истину, если любой её уровень совпадает с образцом. errors.As решает задачу типов:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
	log.Printf("путь: %s, операция: %s", pathErr.Path, pathErr.Op)
}

Ссылка на готовую переменную выглядит непривычно, зато после проверки структура с полями сразу доступна. До Go 1.13 то же самое делали приведением типа и разбором e.Err вручную, новые функции спрятали механику за один вызов. Собственный тип может настроить сравнение методом Is(error) bool, тогда errors.Is учтёт особую эквивалентность. Шлюз к хранилищу объявляет любой сетевой разрыв равным доменной ошибке:

func (e *StorageDownError) Is(target error) bool {
	return target == ErrStorageDown
}

Теперь errors.Is вернёт истину на любом разрыве соединения, и верхним слоям не нужно знать про адреса и таймауты. С отменой контекста дружит тот же механизм:

if errors.Is(err, context.DeadlineExceeded) {
	return ErrTimeout
}
if errors.Is(err, context.Canceled) {
	return nil // клиент ушёл, работа не нужна
}

Обе ошибки - стандартные сторожевые значения пакета context, и ловить их по тексту нельзя. Ловить по тексту вообще нельзя: сообщение ошибки не является интерфейсом, запись strings.Contains(err.Error(), "timeout") ломается при первом же переформатировании. Строка - для человека, тип и значение - для кода.

Для редких случаев, когда вариантов немного и все свои, живёт и старый добрый разбор по типам:

switch e := err.(type) {
case *ValidationError:
	return e.Field
case *NotFoundError:
	return e.Name
}

Он по-прежнему уместен внутри одного пакета, за его пределами честнее errors.As.

Поведенческие ошибки на примере net.Error и json.SyntaxError

Код ошибки отвечает на вопрос "что делать дальше", а не только "что случилось". Классика - сетевой интерфейс:

type Error interface {
	error
	Timeout() bool
	Temporary() bool
}

Вызывающий не разбирает строки, он спрашивает поведение: Temporary вернул истину - подождать и повторить, таймаут - удвоить интервал. Парсер JSON живёт иначе: его SyntaxError несёт поле Offset int64, по которому вызывающий вычисляет строку и колонку сбоя. По соседству лежит os.PathError с полями Op, Path и Err: операция, путь и вложенная причина разложены по отдельным полям, сообщение собирается само. Доменные ошибки работают так же:

type ValidationError struct {
	Field string
	Rule  string
}

func (e *ValidationError) Error() string {
	return e.Field + ": " + e.Rule
}

Поля читаются кодом без парсинга сообщений, и это главный аргумент за ошибки-значения: информация остаётся машиночитаемой на любом этаже стека.

С типизированными ошибками связана и главная ловушка языка: nil-указатель пользовательского типа, возвращённый как error, не равен nil:

func do() error {
	var verr *ValidationError // nil-указатель
	return verr                // интерфейс хранит тип без значения
}

Интерфейсное значение - пара "тип, значение". У возвращённого интерфейса тип есть, а значение пустое, поэтому проверка err != nil даёт истину и фиксирует сбой там, где его нет. Лечение одно: возвращать nil явно, а не переменную указательного типа.

Приёмы против копипасты повторяющихся обработчиков

Первый приём - перенести механику на край системы. HTTP-слой с центральной точкой:

type handler func(http.ResponseWriter, *http.Request) error

func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if err := h(w, r); err != nil {
		var apiErr *APIError
		switch {
		case errors.As(err, &apiErr):
			writeJSON(w, apiErr.HTTPStatus, apiErr)
		default:
			log.Printf("internal: %v", err)
			writeJSON(w, http.StatusInternalServerError, errorResponse("internal"))
		}
	}
}

Каждый обработчик просто возвращает ошибку, а перевод в коды ответа живёт в одном месте. Добавить новый тип - значит добавить один case, а не сорок копий по файлам.

Второй приём - именованные возвращаемые значения с defer для транзакций:

func (s *Store) Save(ctx context.Context, item *Item) (err error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() {
		if err == nil {
			err = tx.Commit()
		} else {
			_ = tx.Rollback()
		}
	}()
	return s.insert(ctx, tx, item)
}

Коммит или откат выбираются сами, а функция остаётся плоской. Третий приём - паники для ошибок программиста: regexp.MustCompile падает при кривом шаблоне в init, потому что чинить это должен автор кода, а не рантайм. В долгоживущем сервере recover страхует обработчик от чужой паники, но превращать его в замену проверок - путь к тем самым скрытым зигзагам.

Четвёртый приём - ошибки из горутин. Голая горутина не умеет возвращать значение, и без обвязки сбой в ней исчезает бесследно. Пакет golang.org/x/sync/errgroup закрывает дыру тремя строками:

g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchUsers(gctx) })
g.Go(func() error { return fetchOrders(gctx) })
if err := g.Wait(); err != nil {
	return fmt.Errorf("load profile: %w", err)
}

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

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

errors.Join в Go 1.20 и судьба предложения try

Параллельные шаги дают несколько сбоев сразу, и с Go 1.20 их можно соединить:

combined := errors.Join(errFetch, errParse, errCache)
return fmt.Errorf("refresh feed: %w", combined)

Разрешены и несколько %w в одном fmt.Errorf:

return fmt.Errorf("refresh: fetch %w; parse %w", errFetch, errParse)

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

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

if err != nil - не случайность, а осознанный выбор дизайна. Три строки на проверку покупают код, где точки отказа названы, решения локальны, а сообщение об ошибке собирается в цепочку, читаемую и человеком, и errors.Is. Девять из десяти ошибок просто уходят на уровень выше с добавкой контекста, и именно эти девять делают стек читаемым как таблицу. Многословность лечится не новым синтаксисом, а грамотной границей, за которой механика обработки живёт один раз.