1. Почему «простыня» ошибок — UX-баг
Когда вы начинаете валидировать аргументы команды, появляется соблазн «отчитаться по-честному»: собрать всё в одну длинную строку и вывести её пользователю. Формально это информативно, но на практике человек видит стену текста, теряется, исправляет одно, запускает снова и получает следующее. В итоге вместо ощущения «программа помогает» появляется ощущение «программа ворчит».
Представьте команду нашего учебного приложения tasker (условный трекер задач):
tasker add --title="" --priority=999 --due="yesterday-ish"
Если мы выведем так:
validate args: title is required; priority must be 1..5; due must be YYYY-MM-DD
то это уже чуть-чуть похоже на человеческий язык, но всё равно плохо сканируется глазами. А если к этому добавятся Op, технические детали, источники ошибок, системные сообщения — получим ту самую «простыню».
Наша цель: короткое резюме + список проблем по пунктам, чтобы пользователь за один запуск понял, что нужно исправить.
Когда multi-error уместен
Multi-error — это не «соберём всё подряд в мешок». Он полезен, когда у операции есть несколько независимых проблем, которые пользователь может исправить параллельно.
Самый типичный случай — валидация аргументов: заголовок пустой, приоритет не в диапазоне, дата не распарсилась. Пользователю выгоднее увидеть всё сразу, чем играть в «угадай следующую ошибку».
Зато для ошибок выполнения (I/O, сеть, доступы) multi-error обычно менее полезен: там чаще «упало и упало», и пытаться продолжать ради сбора ещё пяти падений — сомнительно. Поэтому по умолчанию multi-error — наш друг именно в validation.
2. errors.Join и сбор проблем в стабильном порядке
В Go есть стандартный механизм объединения ошибок: errors.Join. Он создаёт одну ошибку, которая хранит несколько причин.
Это удобно по двум причинам.
Во‑первых, наружу по контракту всё ещё возвращается один error, что отлично ложится на дизайн функций Go.
Во‑вторых, внутри остаётся структура: такую ошибку можно «распаковать» как список причин.
Короткий пример «на пальцах»:
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.Join(
errors.New("missing title"),
errors.New("priority must be 1..5"),
)
fmt.Println(err.Error())
// missing title
// priority must be 1..5
}
Это уже лучше, чем одна строка с ;, но всё ещё не идеальный UX, потому что в реальной программе там появятся «операции» (Op), вложенные ошибки, и формат может стать непредсказуемым. Мы хотим свой контролируемый формат: «резюме + пункты».
Стабильный порядок пунктов (чтобы вывод был предсказуемым)
Стабильность важна. Особенно в CLI, где вывод могут читать не только люди, но и тесты/скрипты. Если порядок пунктов прыгает, пользователю кажется, что программа «живёт своей жизнью».
Самый простой способ получить стабильность — собирать ошибки в []error в заранее заданном порядке проверок. Не через map, не через «как получится», а последовательно: title → priority → due.
Допустим, add принимает три параметра: title, priority, due.
package tasker
import (
"errors"
"strconv"
"time"
)
func validateAddArgs(title, priorityRaw, dueRaw string) error {
var problems []error
if title == "" {
problems = append(problems, errors.New("title is required"))
}
if _, err := strconv.Atoi(priorityRaw); err != nil {
problems = append(problems, errors.New("priority must be integer"))
}
if _, err := time.Parse("2006-01-02", dueRaw); err != nil {
problems = append(problems, errors.New("due must be YYYY-MM-DD"))
}
if len(problems) == 0 {
return nil
}
return errors.Join(problems...)
}
Современный Go делает так, что errors.Join() при пустом наборе возвращает nil, но в учебном коде полезно явно написать if len(problems) == 0 { return nil }, чтобы логика читалась как простая инструкция: «если проблем нет — ошибки нет».
4. Как «распаковать» join-ошибку в список причин
Чтобы красиво печатать multi-error, нам нужен список причин. При этом мы не хотим зависеть от внутренней реализации errors.Join.
В Go это решается через «опциональный интерфейс»: join-ошибка умеет Unwrap() []error.
Мы заведём маленький helper в пакете (например, internal/apperr или просто apperr, как вы уже делали для Kind/AppError):
package apperr
type joiner interface {
Unwrap() []error
}
func UnwrapMany(err error) []error {
if err == nil {
return nil
}
j, ok := err.(joiner)
if !ok {
return []error{err}
}
return j.Unwrap()
}
Заметьте: здесь нет ни errors.As, ни привязки к типам стандартной библиотеки. Мы просто проверяем: «умеет ли эта ошибка раскрываться в список?». Если нет — считаем, что это обычная одиночная ошибка.
5. Формат «резюме + пункты» для stderr
Договоримся о формате, который обычно работает хорошо.
Первая строка — короткое резюме, например:
invalid input:
Дальше — пункты, по одному на строку:
- title is required
- priority must be integer
- due must be YYYY-MM-DD
Это хорошо сканируется глазами, не превращается в роман, и человек может быстро исправить всё за один запуск.
Сделаем функцию FormatProblems, которая принимает ошибку (обычную или join) и возвращает строку для stderr:
package apperr
import (
"strings"
)
func FormatProblems(head string, err error) string {
items := UnwrapMany(err)
if len(items) == 0 {
return ""
}
if len(items) == 1 {
return head + ": " + items[0].Error()
}
var b strings.Builder
b.WriteString(head)
b.WriteString(":\n")
for i := 0; i < len(items); i++ {
b.WriteString("- ")
b.WriteString(items[i].Error())
if i != len(items)-1 {
b.WriteString("\n")
}
}
return b.String()
}
Здесь важны мелочи, которые кажутся «занудством», но делают UX лучше: ":\n" добавляется только при нескольких пунктах, нет лишнего перевода строки в конце, а формат остаётся стабильным и предсказуемым.
6. Интеграция с контрактом ошибок: AppError и KindValidation
Важно не перепутать роли.
errors.Join и список причин — это причина ошибки.
AppError.Kind — это класс ошибки (validation/io/internal).
AppError.Op — это контекст операции (для логов/диагностики).
User-facing сообщение — это отдельный продукт, который мы генерируем на границе CLI.
Например, внутри команды add можно сделать так (упрощённо, без флагов — лишь идея):
package tasker
import (
"example/apperr"
)
func Add(title, priorityRaw, dueRaw string) error {
if err := validateAddArgs(title, priorityRaw, dueRaw); err != nil {
return &apperr.AppError{
Kind: apperr.KindValidation,
Op: "tasker add",
Err: err,
}
}
return nil
}
А уже на границе (где печатаем пользователю) делаем примерно такую логику: если KindValidation, печатай "invalid input" и распакуй причины.
Почему так? Потому что если вы начнёте печатать внутри validateAddArgs, то вы размажете ответственность. В Go принято держать печать и завершение процесса на верхнем уровне, а ниже возвращать ошибки как значения.
7. Примеры вывода и схема потока
Таблица: как должен выглядеть вывод
Чтобы не держать всё в голове, полезно зафиксировать мини-таблицу «что печатать».
| Сценарий | Что вернул код | Что выводим пользователю |
|---|---|---|
| Ошибок нет | |
Ничего (или результат команды в stdout) |
| Одна проблема | обычный |
|
| Несколько проблем | |
и далее пункты |
Смысл в том, что формат предсказуем: пользователь уже по первой строке понимает, что это «ошибка ввода», а дальше получает чек‑лист.
Мини-схема потока: от проверки аргументов до stderr
Иногда помогает картинка, чтобы не спутаться, где что происходит:
flowchart TD
A[Команда CLI получила args] --> B[validate... собирает problems]
B -->|0 проблем| C[return nil]
B -->|1+ проблем| D["errors.Join(problems...)"]
D --> E[оборачиваем в AppError KindValidation]
E --> F[граница CLI: UserMessage/FormatProblems]
F --> G[stderr: резюме + пункты]
Если этот поток выдержан, у вас обычно исчезает 80% «почему тут печатается два раза?» и «почему формат прыгает?» — потому что печать централизована.
8. Типичные ошибки при выводе multi-error в CLI
Ошибка №1: печатать join-ошибку как err.Error() и надеяться, что «и так нормально».
errors.Join действительно формирует строку, но это не ваш UX-контракт. Сегодня там перенос строки, завтра вы обернёте ошибку ещё одним уровнем и получите «операция: err1\nerr2», а послезавтра добавите Op и начнётся «простыня». Гораздо надёжнее распаковать причины и вывести пункты в вашем стабильном формате.
Ошибка №2: вызывать errors.Join(problems...) даже когда проблем нет, и не задумываться о читаемости кода.
Современный Go аккуратно вернёт nil, но читатель кода может не знать этот нюанс (или просто забыть). В учебном коде лучше явно писать if len(problems) == 0 { return nil }, чтобы логика читалась как простой русский текст: «если проблем нет — ошибки нет».
Ошибка №3: собирать ошибки в map и получать случайный порядок пунктов.
Это особенно коварно: вы можете вообще не заметить проблему, пока однажды тест не упадёт, потому что строки поменялись местами. Для validation лучше держать порядок проверок фиксированным и добавлять ошибки в slice по порядку, а если уж очень хочется map (например, по полям), то на печати сортировать ключи.
Ошибка №4: смешивать пользовательский текст и диагностический контекст в одном сообщении.
Если вы начинаете печатать пользователю что-то вроде "tasker add: validateAddArgs: strconv.Atoi: invalid syntax", то человеку приходится быть компилятором. Пользователю нужна формулировка уровня "priority must be integer", а цепочка причин пусть остаётся в ошибке для логов. Идея wrapping как раз в том, чтобы контекст сохранялся для диагностики, но не обязательно «выливался» пользователю целиком.
Ошибка №5: делать multi-error «везде», включая I/O и internal ошибки.
Multi-error — сильный инструмент, но в основном для сценария «исправь ввод». Если попытаться собрать пачку I/O проблем, вы часто получите странное поведение: программа продолжила работу после первой критичной ошибки ради «сбора статистики», а пользователь получил список, из которого не ясно, что делать. Для I/O чаще лучше «упасть быстро», для validation — «показать всё сразу».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ