← назад к разделу

В большинстве языков ошибки — это исключения: что-то ломается, выполнение «выпрыгивает» из функции, и где-то выше его ловит try/catch. В Go всё устроено иначе. Здесь ошибка — это обычное значение, которое функция возвращает наравне с результатом. Разберём, почему так сделано и как с этим жить.

Обязательно

Зачем ошибки как значенияспросят на собеседовании

Главная идея Go: ошибка — это часть результата функции, а не отдельный поток управления. Функция, которая может не справиться, возвращает два значения — результат и error:

func ReadConfig(path string) (Config, error) {
    // ...
}

Вызывающий код видит второе значение прямо перед собой как переменную, и компилятор не даст объявить err и забыть про него. Полной страховки всё же нет: вызов без присваивания (os.Remove(path)) или _ вместо err молча проглотят ошибку — такие места ловит линтер errcheck, а в ревью на них смотрят отдельно. Минус — проверок становится много. Плюс — поток выполнения линеен и предсказуем: ты всегда видишь, где именно что-то может пойти не так.

Короткая формула: в Go не «бросают» ошибку, её возвращают и тут же проверяют.

Что такое errorспросят на собеседовании

error — это встроенный interface с единственным методом:

type error interface {
    Error() string
}

Любой тип, у которого есть метод Error() string, является ошибкой. Это значит, что ошибка — не магия, а обычное значение какого-то типа. Если ошибки нет, функция возвращает nil на месте error.

value, err := strconv.Atoi("42") // превращает строку в число
if err != nil {
    // сюда не зайдём: "42" корректно
}
// value == 42

Подробнее про интерфейсы — в отдельной статье (ссылка ниже); здесь достаточно знать, что error — это контракт «я умею рассказать о себе строкой».

Идиома if err != nil

Базовый приём, который ты будешь писать постоянно:

f, err := os.Open("data.txt")
if err != nil {
    return err // не справились — сообщаем выше
}
// дальше работаем с f, зная, что ошибки нет

Несколько правил, которые делают этот код идиоматичным:

  • Проверяй ошибку сразу после вызова, до использования результата.
  • Если обработать ошибку здесь нельзя — возвращай её выше (return err), пусть разбирается тот, кто знает контекст.
  • При ошибке остальные возвращаемые значения обычно бессмысленны — не используй их.
// типичная цепочка: каждый шаг может сорваться
data, err := os.ReadFile(path)
if err != nil {
    return err
}
cfg, err := parse(data)
if err != nil {
    return err
}
return validate(cfg)

Да, проверок много. Это осознанный выбор языка: явность важнее краткости.

Как создавать ошибки

Два основных способа сделать ошибку.

errors.New — простая ошибка с фиксированным текстом:

import "errors"

var ErrNotFound = errors.New("запись не найдена")

func find(id int) (Item, error) {
    // ...
    return Item{}, ErrNotFound
}

Ошибку часто кладут в переменную с префиксом Err (так называемая sentinel-ошибка) — чтобы выше её можно было сравнить и принять решение.

fmt.Errorf — когда в текст нужно подставить данные:

import "fmt"

func find(id int) (Item, error) {
    // ...
    return Item{}, fmt.Errorf("запись id=%d не найдена", id)
}

fmt.Errorf форматирует строку так же, как fmt.Printf, но возвращает error.

Обёртывание и errors.Is / errors.Asспросят на собеседовании

Когда ошибка поднимается через несколько слоёв, голый текст вроде "запись не найдена" не помогает: непонятно, где и при чём это случилось. Решение — обёртывание (wrapping): добавить контекст, но сохранить исходную ошибку внутри.

Для этого в fmt.Errorf используется глагол %w (от wrap):

func loadUser(id int) (User, error) {
    u, err := repo.find(id)
    if err != nil {
        // добавляем контекст, но не теряем исходную ошибку
        return User{}, fmt.Errorf("loadUser id=%d: %w", id, err)
    }
    return u, nil
}

Теперь текст ошибки накапливает «маршрут» (loadUser id=7: запись не найдена), а внутри по-прежнему лежит оригинал. Достать его помогают две функции.

errors.Is — проверить, что где-то в цепочке обёрток есть конкретная sentinel-ошибка:

_, err := loadUser(7)
if errors.Is(err, ErrNotFound) {
    // да, в глубине это именно "не найдено" — отвечаем соответствующе
}

Сравнивать через == здесь нельзя: обёрнутая ошибка — уже другое значение. errors.Is разворачивает слои за тебя.

errors.As — когда нужна не просто проверка, а доступ к полям ошибки определённого типа:

type ValidationError struct {
    Field string
}

func (e *ValidationError) Error() string {
    return "некорректное поле: " + e.Field
}

var vErr *ValidationError
if errors.As(err, &vErr) {
    // нашли ValidationError в цепочке — можно прочитать vErr.Field
    fmt.Println("проблемное поле:", vErr.Field)
}

Короткая формула: оборачивай через %w, проверяй значение через errors.Is, извлекай тип через errors.As.

repo.find возвращает ErrNotFound, loadUser оборачивает её через %w, обёрнутая ошибка уходит в вызывающий код, а тот достаёт исходную через errors.Is

panic и recover — редкий случайспросят на собеседовании

В Go есть и механизм, похожий на исключения: panic разворачивает стек и аварийно завершает программу, а recover позволяет его перехватить. Но это не замена обычным ошибкам.

panic уместен только для ситуаций «программа в принципе не может продолжать»: нарушен внутренний инвариант, некорректно собрана программа, обращение за границу slice. Для ожидаемых сбоев (файл не найден, неверный ввод) — всегда возвращай error.

func recovered() {
    defer func() {
        if r := recover(); r != nil {
            // перехватили панику, не дали процессу упасть
            fmt.Println("восстановились после:", r)
        }
    }()
    panic("что-то пошло совсем не так")
}

recover работает только внутри defer-функции. Типичное применение — на границе, чтобы одна сбойная задача не уронила весь сервис. Правило по умолчанию: ошибки возвращай, panic не используй.

Ошибки на уровне HTTP и backend

Этот раздел — про язык. Как ошибки превращаются в HTTP-ответы (коды статусов, единый формат тела, логирование на границе обработчика) — отдельная тема backend-уровня. Об этом — в статье Ошибки и HTTP в Go из раздела про серверную разработку.

Дополнительно: при первом чтении можно пропустить

Глубже: несколько ошибок сразу, стиль обёртки и nil, который не nilрасширенноеспросят на собеседовании

Валидация формы находит не одну ошибку, а три, и терять две из них обидно. С Go 1.20 несколько ошибок собирают в одну через errors.Join, и errors.Is/errors.As умеют заглядывать в каждую:

живой пример

package main

import (
    "errors"
    "fmt"
)

var ErrEmptyName = errors.New("пустое имя")
var ErrBadEmail = errors.New("некорректная почта")

func validate(name, email string) error {
    var errs []error
    if name == "" {
        errs = append(errs, ErrEmptyName)
    }
    if email == "" {
        errs = append(errs, ErrBadEmail)
    }
    return errors.Join(errs...) // nil, если срез пуст
}

func main() {
    err := validate("", "")
    fmt.Println(err)                       // обе ошибки, каждая с новой строки
    fmt.Println(errors.Is(err, ErrBadEmail)) // true
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Тем же способом fmt.Errorf принимает несколько %w в одной строке. Обратную операцию — снять один слой обёртки — делает errors.Unwrap, но в прикладном коде она почти не нужна: Is и As ходят по цепочке сами.

Про стиль. Текст ошибки пишут со строчной буквы и без точки в конце, потому что он станет серединой чужой фразы: loadUser id=7: запись не найдена. Контекст добавляют коротким «что делали», а не пересказом. И глагол %v вместо %w — не безобидная опечатка: текст сохранится, а цепочка оборвётся, и errors.Is выше перестанет находить исходную ошибку.

Последняя ловушка не про пакет errors, а про интерфейсы: функция, которая объявила переменную типа *MyError и вернула её пустой, отдаст не nil, а error с типом внутри — и if err != nil сработает. Разбор с примером — в интерфейсах, раздел про nil внутри интерфейса.

Коротко

  • Ошибка в Go — это значение типа error (интерфейс с методом Error() string), а не исключение.
  • Функция возвращает error рядом с результатом; вызывающий проверяет его идиомой if err != nil.
  • Создавай ошибки через errors.New (фиксированный текст) или fmt.Errorf (с данными).
  • Оборачивай ошибку через %w, чтобы добавить контекст и сохранить оригинал внутри.
  • errors.Is ищет в цепочке конкретную sentinel-ошибку, errors.As — извлекает ошибку нужного типа.
  • panic/recover — только для неустранимых сбоев; для ожидаемых ошибок всегда возвращай error.
  • errors.Join и несколько %w собирают ошибки в одну; текст ошибки — со строчной буквы и без точки, %v вместо %w рвёт цепочку.

Что пощупать

В практикуме remodov/marketplace-system-go ошибки двух видов: значение ErrInvalid, которое узнают через errors.Is, и тип *OutOfStockError с числами внутри для errors.As; обработчик переводит их в 400 и 409.

Код: errors.go, handler.go.

Сделаем сами

Ветка step-03-commands-and-errors.

Что почитать дальше