1. Навіщо взагалі потрібен multi-error під час імпорту
Коли ви пишете імпорт CSV, дуже легко за звичкою зробити «fail fast»: знайшли проблему в рядку № 2 — одразу return err. Підхід чесний, але не завжди вдалий. Користувач виправить рядок № 2, запустить імпорт знову, побачить помилку в рядку № 17, потім — у рядку № 23… і почне підозрювати, що ви з нього знущаєтеся. Іноді — не без підстав.
Імпорт — майже завжди пакетна операція. Користувач приніс файл, а ваше завдання — дати звіт, що з ним не так. І що більше проблем ви покажете за один запуск у розумних межах, то коротшим буде цикл «виправив → знову запустив → знову виправив».
До того ж це просто економить час: помилки в даних часто йдуть «пачками». А ми, як розробники, хочемо бути не лише суворими, а й корисними.
Давайте спершу подивимося на «наївний» код, який псує UX.
// Поганий UX: зупиняємося на першій же помилці.
if err := validateRecord(rec, recNo); err != nil {
return nil, err
}
Він коректний, але для імпорту частіше потрібна інша модель: збирати проблеми й продовжувати.
Для наочності можна уявити імпорт так:
flowchart TD
A[Читаємо CSV] --> B[Парсимо запис]
B --> C{Валідація успішна?}
C -- так --> D[Додаємо до результату]
C -- ні --> E[Запам’ятовуємо помилку]
D --> F{Є ще записи?}
E --> F
F -- так --> B
F -- ні --> G[Якщо були помилки: повернути всі]
І ось «повернути всі» — це і є тема лекції.
2. Нагадування: помилки в Go — це значення
Перед тим як писати errors.Join, корисно повернутися до базової ідеї Go: помилки — це значення. Це не просто філософія для горнятка з написом «I ❤️ Go», а практичний принцип: помилки можна складати, обгортати, аналізувати й обирати стратегію обробки.
У Go 1.13 додали стандартний механізм «ланцюжка причин»: помилка може обгортати іншу помилку через Unwrap() error, і тоді errors.Is/errors.As уміють шукати потрібну причину всередині ланцюжка.
А починаючи з Go 1.20 з’явився стандартний спосіб зробити майже те саме, але для кількох помилок: errors.Join. Він повертає одну помилку, всередині якої лежить список причин, доступний через Unwrap() []error.
Ідея проста: назовні ми повертаємо одне значення error — так зручніше для API, — а всередині можемо зберігати багато деталей.
3. Мінімальна модель: імпорт задач із CSV
Щоб не писати «абстрактний імпорт абстрактних сутностей», давайте продовжимо нашу навчальну лінію із задачами (tasks). Нехай CSV містить такі поля:
- id — ціле число
- title — рядок, обов’язковий
- done — true/false
Структура в Go нам уже знайома з попередніх лекцій про struct:
package task
type Task struct {
ID int
Title string
Done bool
}
Сигнатура імпорту, до якої будемо прагнути:
func ImportCSV(r io.Reader) ([]Task, error)
Чому ми повертаємо і задачі, і error? Тому що стратегія «прийняти валідні рядки, а про невалідні відзвітувати» — цілком робоча. Іноді ви можете обрати підхід «якщо є хоч одна помилка — нічого не імпортуємо», але навіть у такому разі multi-error усе одно корисний: звіт користувачу стає повнішим.
Зараз ми не сперечаємося про політику «частково приймати чи ні». Ми вчимося техніки: як коректно зібрати кілька помилок.
4. Що таке errors.Join і чому він зручний
errors.Join — це функція стандартної бібліотеки, яка приймає кілька помилок і повертає одну. Найважливіше для практики імпорту:
- errors.Join(nil, nil) дає nil — тобто помилок не було.
- errors.Join(err1, nil, err2) ігнорує nil і об’єднує тільки реальні помилки.
- Повернена помилка підтримує «розгортання» списку причин через Unwrap() []error.
Це означає, що ми можемо зібрати []error у циклі, а наприкінці зробити:
return tasks, errors.Join(errs...)
І отримати один error, який можна:
- просто надрукувати — часто цього вже достатньо для CLI;
- аналізувати програмно — наприклад, перевіряти errors.Is/errors.As для кожної причини. Механізм ґрунтується на ідеї unwrap, як і в Go 1.13.
Важливо: errors.Join — це не «магія», а просто акуратний стандартний контейнер для набору помилок. В імпорті це саме те, що треба.
5. Робимо помилки самодостатніми: додаємо контекст рядка
Якщо ми просто складатимемо в errs помилки на кшталт errors.New("invalid id"), підсумковий звіт буде марним: «invalid id» — де? у якому рядку? яке значення? яка колонка?
Тому нам потрібен невеликий тип помилки, який додає контекст: номер запису, ім’я колонки й вихідне значення. І бажано — зберігає «причину» через Unwrap, щоб ми могли використовувати errors.Is/errors.As для вкладеної помилки.
Мінімальний варіант:
package taskcsv
import "fmt"
type RowError struct {
Row int
Col string
Val string
Err error
}
func (e *RowError) Error() string {
return fmt.Sprintf("row %d, col %s: %q: %v", e.Row, e.Col, e.Val, e.Err)
}
func (e *RowError) Unwrap() error {
return e.Err
}
Зверніть увагу: ми зробили Unwrap() error, тобто одну конкретну причину помилки. Це корисно, щоб errors.As і errors.Is могли дістатися до вихідної причини — наприклад, до помилки strconv.Atoi.
6. Збираємо помилки в циклі: валідація не повинна ламати весь імпорт
Тепер уявімо, що в нас є функція parseTask, яка намагається перетворити []string — один CSV-запис — на Task. Якщо не вийшло, вона повертає error.
Важливий момент: якщо parseTask повертає помилку, ми не зобов’язані одразу припиняти імпорт. Ми можемо записати її в список і рухатися далі.
Скелет імпорту виглядає так:
package taskcsv
import (
"encoding/csv"
"errors"
"io"
)
func ImportCSV(r io.Reader) ([]Task, error) {
cr := csv.NewReader(r)
header, err := cr.Read()
if err != nil {
return nil, err
}
_ = header // заголовок обробимо «як слід» у наступній лекції
var tasks []Task
var errs []error
recNo := 1 // header = 1
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
recNo++
t, err := parseTask(rec, recNo)
if err != nil {
errs = append(errs, err)
continue
}
tasks = append(tasks, t)
}
return tasks, errors.Join(errs...)
}
Зверніть увагу на дві різні гілки обробки.
Ми виходимо одразу, якщо сталася помилка читання файла або CSV-парсингу. Наприклад, потік пошкоджено. Це «технічна» помилка — далі робити імпорт безглуздо.
Але якщо це помилка даних конкретного рядка, ми додаємо її в errs і продовжуємо.
7. Як акуратно об’єднувати помилки: допоміжна функція joinAll
Прямо писати errors.Join(errs...) можна, але новачкам часто корисно зробити намір явним: якщо помилок немає, повернути nil.
Чому? Бо errs — це слайс, і хочеться, щоб код читався як чітка інструкція. Так менше шансів випадково повернути «порожню» помилку.
Зробімо невелику допоміжну функцію:
package taskcsv
import "errors"
func joinAll(errs []error) error {
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
І використаємо:
return tasks, joinAll(errs)
Це дрібниця, але вона робить код більш самодокументованим. А читабельність — це така річ, яка починає цінуватися рівно в той момент, коли ви намагаєтеся налагодити імпорт у п’ятницю ввечері.
8. Приклад parseTask: парсимо поля й додаємо контекст
Нехай rec — це []string{ "10", "Buy milk", "false" }.
Ми розпарсимо id, перевіримо title, розпарсимо done. Помилки загорнемо в RowError, щоб угорі отримати «де і чому».
package taskcsv
import (
"errors"
"strconv"
"strings"
)
var ErrEmptyTitle = errors.New("поле title є обовʼязковим")
func parseTask(rec []string, row int) (Task, error) {
idRaw := strings.TrimSpace(rec[0])
id, err := strconv.Atoi(idRaw)
if err != nil {
return Task{}, &RowError{Row: row, Col: "id", Val: idRaw, Err: err}
}
title := strings.TrimSpace(rec[1])
if title == "" {
return Task{}, &RowError{Row: row, Col: "title", Val: rec[1], Err: ErrEmptyTitle}
}
doneRaw := strings.TrimSpace(rec[2])
done, err := strconv.ParseBool(doneRaw)
if err != nil {
return Task{}, &RowError{Row: row, Col: "done", Val: doneRaw, Err: err}
}
return Task{ID: id, Title: title, Done: done}, nil
}
Тут є дві важливі практичні ідеї.
Перша — ми не втрачаємо вихідну причину: якщо Atoi/ParseBool повернули помилку, вона потрапляє в Err і доступна через Unwrap() error.
Друга — ми вводимо «свої» доменні помилки (ErrEmptyTitle). Це зручно, бо такі помилки можна стабільно розпізнавати через errors.Is — зокрема й усередині обгорток.
9. Як показувати й обробляти об’єднану помилку
Що побачить користувач
Уявімо, що в CSV є дві проблеми: в одному рядку id не число, а в іншому — порожній title.
Ми накопичили помилки, зробили errors.Join і повернули одну помилку нагору. Що буде, якщо її надрукувати?
Ось приклад «демо-виводу» — спрощено:
if err != nil {
println(err.Error())
}
Сенс у тому, що Join зазвичай форматує результат так, щоб було видно кілька повідомлень. Для CLI-утиліти це вже непогано: користувач побачить кілька проблем і зможе виправити файл за один прохід.
Але інколи хочеться зробити вивід привабливішим: наприклад, додати префікс «Import failed:» і далі вивести все по рядках. Для цього нам потрібно вміти дістати окремі причини з об’єднаної помилки.
Як розгорнути errors.Join назад у список помилок
errors.Join повертає тип, який усередині тримає список і надає його через метод Unwrap() []error.
Цей тип не обов’язково експортований, тому зазвичай ми робимо type assertion до інтерфейсу:
package taskcsv
type unwrapper interface {
Unwrap() []error
}
func unwrapMany(err error) []error {
u, ok := err.(unwrapper)
if !ok {
return nil
}
return u.Unwrap()
}
Тепер можна зробити «людський» звіт:
errs := unwrapMany(err)
for _, e := range errs {
println("-", e.Error())
}
Чому це важливо саме в імпорті? Бо імпорт — це саме те місце, де користувач очікує звіт «списком проблем». А errors.Join дозволяє зберегти його в одній error — зручно для API, — але водночас за потреби розгорнути й красиво вивести.
Перевірка errors.Is за нашими помилками всередині joined error
Нехай ми хочемо зрозуміти: «а чи був у файлі хоча б один рядок із порожнім title?». Ми зробили ErrEmptyTitle як окрему помилку — чудово. Тепер можна:
if errors.Is(err, ErrEmptyTitle) {
println("У файлі є задачі без title") // приклад повідомлення
}
Сенс саме в тому, що ми не порівнюємо рядки, а перевіряємо семантику помилки через errors.Is, який уміє «пірнати» в обгортки.
Це особливо приємно, коли помилок багато: ми можемо і звіт користувачу показати, і прийняти рішення в коді — наприклад, повернути exit code 2 для «помилок даних».
10. Стратегії імпорту: fail-fast і collect-all
Коли ви проєктуєте імпорт, ви по суті обираєте стратегію. Нижче — компактна таблиця, щоб зафіксувати, що саме змінюється.
| Стратегія | Поведінка | Плюси | Мінуси | Як реалізувати |
|---|---|---|---|---|
| Fail-fast | Зупинилися на першій помилці | Код простіший, менше стану | Поганий UX для пакетного імпорту | return err одразу |
| Collect-all | Зібрали всі помилки й продовжили | Кращий UX, менше повторних запусків | Код трохи складніший, треба зберігати помилки | errs = append(errs, err) + errors.Join |
errors.Join тут хороший тим, що він не змушує вас вигадувати власний «мега-тип помилки» і не порушує контрактів: ви все так само повертаєте звичайний error, просто всередині може бути кілька причин.
11. Типові помилки
Помилка № 1: збирати помилки у вигляді рядків, а не error.
Іноді хочеться зробити []string і потім склеїти через "\n". Це швидко, але ви втрачаєте головну перевагу Go: помилки — це значення. Ви більше не зможете зробити errors.Is, не зможете дістатися до strconv.Atoi, не зможете обробити один клас помилок інакше, ніж інший. Набагато краще накопичувати саме []error, а текст — це вже питання відображення.
Помилка № 2: повертати errors.Join(errs...), але забути про випадок «помилок немає».
Формально errors.Join() і так поверне nil, якщо всі аргументи nil, але новачки часто плутаються, коли errs порожній, коли в ньому лежать nil, і чому інколи «помилка є, але не друкується». Найспокійніший варіант — тримати joinAll(errs) з явною перевіркою len(errs) == 0.
Помилка № 3: не додавати контекст — рядок, колонку й значення.
invalid syntax без указівки «де саме» в CSV-файлі — це майже знущання. В імпорті завжди додавайте контекст: номер запису, ім’я колонки й вихідне значення. Інакше користувач шукатиме проблему очима по всьому файлу.
Помилка № 4: плутати помилки читання CSV і помилки даних.
Якщо csv.Reader повернув помилку читання або парсингу, продовжувати зазвичай безглуздо: ви не знаєте, де перебуваєте в потоці. А от помилки даних конкретного запису — якраз те, що варто накопичувати. Якщо змішати ці класи, можна отримати ситуацію «ми продовжили імпорт після того, як потік став некоректним», і результати будуть непередбачуваними.
Помилка № 5: намагатися «розібрати» multi-error через err.Error() і регулярні вирази.
Щойно ви починаєте розбирати текст помилки, ви майже напевно програєте: формат може змінитися, локалізація може змінитися, та й узагалі це крихко. Якщо вам потрібно програмно обробити причини — використовуйте errors.Is/errors.As і Unwrap-інтерфейси.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ