1. Зачем вообще нужно «несколько ошибок», а не одна
Если до этого момента у вас в голове живёт правило «ошибка — одна, иначе будет бардак», это нормально: в большинстве операций действительно выгодно остановиться на первой проблеме. Но иногда «первую» ошибку выбрать нечестно, потому что пользователь всё равно столкнётся со второй, третьей и четвёртой — просто по очереди. В таких местах errors.Join превращает UX из «бесконечной переписки» в «один внятный отчёт».
Представьте валидацию формы (пусть даже консольной): имя пустое, возраст отрицательный, и ещё формат e-mail странный. Если мы возвращаем только первую ошибку, пользователь исправляет имя, запускает снова, получает про возраст, исправляет, запускает снова… и в какой-то момент начинает подозревать, что программа издевается.
В импорте данных (например, список строк) ситуация ещё ярче: вы хотите знать все плохие строки, а не «первую плохую строку» — иначе импорт превращается в «запускай 200 раз, пока не исправишь всё».
Чтобы не быть голословными, заведём учебный мини‑сценарий, который будем развивать по ходу лекции: TaskBox — консольная утилита, которая принимает «задачи» и проверяет входные данные. Пока без файлов, без структур и без сложной архитектуры: только функции, строки, числа и ошибки.
2. Как работает errors.Join на практике
errors.Join(err1, err2, ...) — это стандартная функция, которая объединяет несколько ошибок в одну «мульти‑ошибку» (multi-error). Она появилась в стандартной библиотеке начиная с Go 1.20. Важно, что это не «склейка строк», а именно ошибка, которая оборачивает набор причин так, что errors.Is/errors.As могут эти причины находить.
С практической точки зрения у errors.Join есть несколько полезных свойств.
Во‑первых, если передать туда только nil (или вообще ничего), результатом будет nil. То есть вы можете писать код «собираю ошибки в слайс → errors.Join(errs...)», и он естественно вернёт nil, когда проблем нет.
Во‑вторых, строковое представление объединённой ошибки обычно выглядит как несколько сообщений, разделённых переводом строки — это удобно для человека, который читает вывод.
Минимальный пример
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.Join(errors.New("name required"), errors.New("age invalid"))
fmt.Println(err) // name required\nage invalid
}
Заметьте характерную вещь: это одна переменная err, но она хранит информацию о двух проблемах. Для программы это тоже одна ошибка, но структурированная.
3. Паттерн: валидация с отчётом обо всех проблемах
Теперь сделаем первый маленький шаг в TaskBox: напишем валидацию параметров задачи. Пусть у задачи есть title и priority. Мы хотим проверить сразу несколько условий и вернуть сразу все нарушения.
Sentinel-ошибки для узнаваемых причин
Сначала объявим sentinel-ошибки. Это будут «узнаваемые» причины, на которые код сможет реагировать через errors.Is:
package main
import "errors"
var (
ErrTitleRequired = errors.New("title required")
ErrPriorityOutOfRange = errors.New("priority out of range")
)
Собираем нарушения в слайс и объединяем через errors.Join
Теперь напишем функцию validateTask(title, priority) так, чтобы она не останавливалась на первой ошибке:
package main
import "errors"
func validateTask(title string, priority int) error {
var errs []error
if title == "" {
errs = append(errs, ErrTitleRequired)
}
if priority < 1 || priority > 5 {
errs = append(errs, ErrPriorityOutOfRange)
}
return errors.Join(errs...)
}
Обратите внимание на момент, который часто удивляет новичков: мы возвращаем errors.Join(errs...), даже если errs пустой. Это нормально: errors.Join в этом случае вернёт nil, и вызывающий код увидит «ошибки нет».
Кстати, троеточие ... читается как «развернуть слайс в список аргументов». То есть если errs — это []error, то errs... превращается в «передать каждый элемент как отдельный аргумент». Без этого Go не сможет передать «слайс как много аргументов», и компилятор будет ругаться (и, в целом, справедливо).
Чтобы картина была нагляднее, вот простая блок‑схема логики этой функции:
flowchart TD
A[validateTask] --> B{title пустой?}
B -->|да| C[добавить ErrTitleRequired]
B -->|нет| D[пропустить]
C --> E{priority 1..5?}
D --> E
E -->|нет| F[добавить ErrPriorityOutOfRange]
E -->|да| G[пропустить]
F --> H["return errors.Join(errs...)"]
G --> H
Эта схема показывает важный стиль: мы не «выбираем первую ошибку», а собираем набор проблем и возвращаем его.
Как добавлять контекст к каждой причине и не превращать всё в кашу
Сбор нескольких ошибок — это хорошо, но дальше возникает вопрос: как сделать сообщения понятными? Если вы просто вернёте ErrTitleRequired, пользователь увидит "title required" — и ещё не факт, что поймёт, где это всплыло (создание? импорт? обновление?).
Мы уже знаем, что fmt.Errorf("context: %w", err) добавляет контекст и сохраняет причину внутри. Это свойство появилось в Go 1.13 и стало базовым инструментом построения «цепочки причин». И вот приятный момент: этот же приём отлично комбинируется с errors.Join.
Сделаем контекст «на уровне поля», чтобы multi-error был самодокументируемым:
package main
import (
"errors"
"fmt"
)
func validateTask(title string, priority int) error {
var errs []error
if title == "" {
errs = append(errs, fmt.Errorf("title: %w", ErrTitleRequired))
}
if priority < 1 || priority > 5 {
errs = append(errs, fmt.Errorf("priority: %w", ErrPriorityOutOfRange))
}
return errors.Join(errs...)
}
Теперь человек видит сразу «title: …» и «priority: …». При этом код всё ещё может делать errors.Is(err, ErrTitleRequired) — потому что внутри каждой обёртки лежит sentinel, а errors.Is умеет ходить по цепочке.
И здесь важно не переборщить с «красотой текста». Если вы начнёте писать контексты вроде «ошибка валидации поля title при создании задачи...», вы получите длинные простыни, которые тяжело читать. Хороший контекст обычно короткий и «операционный»: title, priority, parse, import line 17.
4. Паттерн: импорт с накоплением ошибок по строкам
В импорте данных есть классическая боль: вы читаете много записей, и часть из них может быть кривой. Если вы возвращаете первую ошибку, вы заставляете пользователя чинить по одной записи за раз. В результате импорт либо становится мучением, либо люди начинают «забивать» и отключать проверки. Оба варианта плохие: первый убивает UX, второй — качество данных.
Сделаем простой «импорт задач» в TaskBox. Пусть входом будет слайс строк, а формат строки — "title;priority". Мы не будем сегодня углубляться в продвинутый парсинг: используем strings.Cut и strconv.Atoi.
Разбор одной строки
package main
import (
"fmt"
"strconv"
"strings"
)
func parseTaskLine(line string) (string, int, error) {
title, prioStr, ok := strings.Cut(line, ";")
if !ok {
return "", 0, fmt.Errorf("bad format: %q", line)
}
priority, err := strconv.Atoi(prioStr)
if err != nil {
return "", 0, fmt.Errorf("priority is not int: %w", err)
}
return title, priority, nil
}
Импорт: собираем все ошибки, добавляя контекст номера строки
Мы пройдёмся по строкам, и каждую проблему положим в errs, добавляя контекст номера строки:
package main
import (
"errors"
"fmt"
)
func importTasks(lines []string) error {
var errs []error
for i, line := range lines {
_, _, err := parseTaskLine(line)
if err != nil {
errs = append(errs, fmt.Errorf("line %d: %w", i+1, err))
continue
}
}
return errors.Join(errs...)
}
Это пока только парсинг. Добавим сюда нашу валидацию validateTask, чтобы собирать проблемы «и парсинга, и бизнес-правил»:
package main
import (
"errors"
"fmt"
)
func importTasks(lines []string) error {
var errs []error
for i, line := range lines {
title, prio, err := parseTaskLine(line)
if err != nil {
errs = append(errs, fmt.Errorf("line %d: %w", i+1, err))
continue
}
if err := validateTask(title, prio); err != nil {
errs = append(errs, fmt.Errorf("line %d: %w", i+1, err))
}
}
return errors.Join(errs...)
}
Смысл этого кода не в том, что он «идеален», а в том, что он показывает стиль: мы не прекращаем импорт на первой кривой записи, а собираем отчёт. Это очень по‑взрослому: ваш код ведёт себя как хороший преподаватель — замечает все ошибки в контрольной, а не ставит «2» за первую опечатку и уходит пить чай.
Проверяемость: errors.Is и errors.As работают и с Join
Частая ошибка мышления: «Если я объединил ошибки, то теперь это просто текст». Нет. errors.Join объединяет ошибки структурно, и инструменты распознавания продолжают работать.
Почему это важно? Потому что на верхнем уровне программы вы часто хотите решить: это ошибка валидации (показать пользователю), или что-то системное (прервать выполнение). Мы ещё будем формализовывать таксономию ошибок, но даже до неё полезно уметь делать проверки по sentinel.
Давайте напишем небольшой main, который вызывает importTasks и проверяет, встречалась ли конкретная причина:
package main
import (
"errors"
"fmt"
)
func main() {
lines := []string{";2", "Buy milk;10", "Read book;3"}
err := importTasks(lines)
fmt.Println(err)
// line 1: title: title required
// line 2: priority: priority out of range
fmt.Println(errors.Is(err, ErrTitleRequired)) // true
fmt.Println(errors.Is(err, ErrPriorityOutOfRange)) // true
}
Здесь происходит сразу два полезных эффекта. Человек получает понятный отчёт, а код получает возможность программно отличить классы проблем. И это как раз соответствует идее «ошибки — значения»: мы их не просто печатаем, мы на них реагируем.
Внутренне это работает потому, что Go уже давно умеет раскрывать цепочки ошибок (Unwrap → errors.Is/errors.As). В Go 1.13 это было добавлено как стандартная «механика цепочки». А errors.Join добавляет к этой механике «ветвление»: теперь у ошибки может быть не одна причина, а набор причин.
5. Типичные ошибки при использовании errors.Join
Ошибка №1: склеивать ошибки строками вместо errors.Join.
Новички часто делают fmt.Errorf("bad input: %v, %v, %v", err1, err2, err3) или вручную соединяют строки через +. Для человека это ещё как-то работает, но для кода вы убиваете проверяемость: errors.Is и errors.As не смогут «увидеть» причины, потому что вы не сформировали структуру ошибок, а просто сделали текст.
Ошибка №2: класть nil в слайс ошибок и удивляться странному выводу.
Слайс []error должен содержать только реальные ошибки. Если вы где-то делаете errs = append(errs, err) без проверки if err != nil, в errs могут попасть nil. Это приводит к мутным ситуациям: где-то кажется, что «ошибки были», но при объединении часть информации теряется или выглядит странно. Самое спокойное правило — добавлять в errs только внутри if err != nil { ... }.
Ошибка №3: забыть, что errors.Join(errs...) возвращает nil, когда ошибок нет.
Это не баг, это фича. Но если вы ожидаете «не nil, но пустая ошибка», вы начнёте писать лишние проверки. Лучше принять модель Go: «нет ошибки» означает nil. Если хотите печатать «всё ок», делайте это в вызывающем коде через if err == nil.
Ошибка №4: смешать роли Join и wrapping так, что контекст становится нечитаемым.
Хороший порядок обычно такой: сначала вы собираете причины (включая контекст уровня поля или строки), затем объединяете через errors.Join, а уже потом, при необходимости, добавляете общий контекст операции ("import tasks: %w"). Если сделать наоборот и завернуть каждый чих в «import tasks: ...», вывод начнёт повторяться и станет похож на лог, написанный в панике за 5 минут до дедлайна.
Ошибка №5: пытаться анализировать multi-error через err.Error() и strings.Contains.
С multi-error это особенно соблазнительно: «ну там же список ошибок, давайте поищем подстроку». Так делать не стоит, потому что текст может измениться, формат может отличаться, а вы превращаете структуру обратно в строку. Если вам нужно отличать виды проблем, делайте это через errors.Is (по sentinel) или errors.As (по типу причины), а строку оставляйте для человека.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ