JavaRush /Курсы /Go SELF /errors.Join — объеди...

errors.Join — объединяем несколько ошибок

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

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 уже давно умеет раскрывать цепочки ошибок (Unwraperrors.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 (по типу причины), а строку оставляйте для человека.

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