1. Чому «просто вивести err» не допомагає
Коли тільки починаєш писати програми, дуже хочеться зробити так: «якщо сталася помилка — надрукувати err і все». Це природно: програма принаймні не мовчить. Але доволі швидко виявляється, що сирий текст помилки часто або надто технічний (invalid character '}' looking for beginning of object key string), або надто загальний (unexpected EOF), або взагалі ні про що (cannot unmarshal number into Go struct field ...).
У Go важливо памʼятати ідею errors are values — помилки не магічні закляття, а звичайні значення, які можна аналізувати й покращувати. Тому хороша стратегія виглядає так: усередині програми ми зберігаємо причину максимально точно, щоб код міг її розпізнати, а на межі застосунку (CLI, імпорт, конфігурація) перетворюємо її на зрозуміле людині повідомлення. Це схоже на переклад: усередині команди ви говорите «мовою розробників», а користувачеві — нормальною мовою.
2. Контекст операції та обгортання помилок
Уявіть, що ви отримуєте помилку unexpected EOF. Звідки вона? З файла? Із stdin? Чи читали ми tasks.json? Або config.json? Чи це обрізаний шматок даних посередині?
Ось тут і зʼявляється контекст операції: коли помилка підіймається вгору стеком, кожен шар додає, що саме він робив. У Go це зазвичай роблять через fmt.Errorf("...: %w", err). Ключовий момент — саме "%w": так ми обгортаємо помилку, зберігаючи початкову причину всередині ланцюжка, щоб її можна було розпізнати через errors.Is/errors.As.
Мініприклад: читаємо JSON задач і додаємо контекст:
package main
import "fmt"
func loadTasksJSON() error {
err := fmt.Errorf("unexpected EOF") // уявімо, що це повернув json.Decoder
return fmt.Errorf("читання файлу задач: %w", err)
}
Якщо роздрукувати err.Error(), людина побачить читання файлу задач: unexpected EOF. Це вже краще: стало зрозуміло, де сталася помилка.
Але тут є важлива тонкість: обгортаючи помилку, ви робите її частиною контракту. Якщо ви віддаєте назовні помилку з чужого пакета (наприклад, sql.ErrNoRows або внутрішню структуру помилок бази), ви ніби обіцяєте користувачам вашого коду, що ця причина там буде завжди. У блогах про Go окремо наголошують: wrapping — це рішення на рівні API, а не просто «гарний текст».
Практичне правило для нашого рівня: якщо помилка є «деталлю реалізації» і ви не хочете виносити її назовні, можна додати контекст через "%v" — не через "%w". Але на межі введення JSON причина найчастіше корисна, бо вона пояснює, що саме не так із вхідними даними.
"%w" і "%v": чому це рішення про контракт
Дуже поширена помилка новачка — думати, що "%w" потрібен просто «щоб було гарно». Насправді "%w" — це не про красу, а про те, щоб код вище у стеку міг сказати: «ага, всередині є *json.SyntaxError» і витягнути деталі через errors.As.
Go 1.13 формалізував wrapping як стандарт: "%w" зберігає ланцюжок причин, а errors.Is/errors.As бачать увесь цей ланцюжок. Це зручно, але вимагає дисципліни: якщо ви обгортаєте чужу помилку й віддаєте назовні, ви тим самим обіцяєте, що цю причину можна буде розпізнати.
У матеріалах про помилки окремо підкреслюють, що це може «протекти» через межі абстракцій: якщо деталь реалізації стала доступною, ви вже не зможете легко замінити її, не зламавши користувачів.
Для нашого сценарію JSON-вводу wrapping майже завжди виправданий: причина помилки — частина контракту введення. Якщо JSON пошкоджений, це не «деталь реалізації», а суть проблеми.
3. Повідомлення для користувача та причина для діагностики
У прикладному коді майже завжди є дві правди про помилку. Перша правда — коротке людське повідомлення: «у файлі задач пошкоджений JSON». Друга правда — технічна причина: «SyntaxError at offset 183».
Класичний прийом зі світу Go — мати структуру помилки, де окремо зберігається те, що показуємо користувачеві, і те, що лишаємо для логів та діагностики. У старих, але дуже показових прикладах Go-коду це роблять через appError з полями Message (для користувача) і Error (для логів і діагностики).
Ми зробимо схожу ідею, тільки простіше й без HTTP.
Мінімальна «користувацька» помилка з unwrap
package main
type InputError struct {
Msg string
Err error
}
func (e *InputError) Error() string { return e.Msg }
func (e *InputError) Unwrap() error { return e.Err }
Зверніть увагу на стиль: Error() повертає коротке повідомлення, яке не соромно показати людині. А Unwrap() зберігає причину для errors.As/errors.Is.
Маленький «перекладач» помилок JSON
Тепер зберемо центральну ідею: зробимо функцію, яка приймає помилку — найімовірніше, уже обгорнуту контекстом, — намагається витягнути корисні деталі й повертає *InputError з людським Msg та початковою причиною всередині.
Почнемо з простого каркаса. Він спеціально короткий, щоб ви могли переписати його самі без відчуття, що читаєте чужу дипломну роботу.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
)
func wrapJSONInput(err error) error {
if errors.Is(err, io.EOF) {
return &InputError{Msg: "очікувався JSON, але введення порожнє", Err: err}
}
var se *json.SyntaxError
if errors.As(err, &se) {
msg := fmt.Sprintf("помилка синтаксису JSON, позиція %d", se.Offset)
return &InputError{Msg: msg, Err: err}
}
return &InputError{Msg: "не вдалося прочитати JSON", Err: err}
}
Зверніть увагу на стиль: повідомлення короткі, без крапки в кінці та без «Error:». І так — ваша програма тепер звучить як нормальний помічник, а не як принтер, що страждає.
4. Типізовані помилки encoding/json
Помилки encoding/json — не просто рядки. Деякі з них мають конкретні типи, і це справжній подарунок: можна дістати структуровані деталі й зробити повідомлення розумнішим.
Найкорисніші на практиці:
- *json.SyntaxError — синтаксис JSON зламаний. Важливе поле — Offset (позиція в байтах). Ідея з Offset і тим, що його можна використати для точнішого повідомлення (наприклад, рядок/колонка), зустрічається навіть в офіційних матеріалах про обробку помилок.
- *json.UnmarshalTypeError — JSON валідний, але тип не збігся з вашим struct (наприклад, "id": "abc" замість числа). Там часто є назва поля та інформація про те, що очікувалося.
Скелет розпізнавання через errors.As виглядає так:
package main
import (
"encoding/json"
"errors"
)
func isSyntax(err error) bool {
var se *json.SyntaxError
return errors.As(err, &se)
}
А от так можна дістати деталі:
package main
import (
"encoding/json"
"errors"
"fmt"
)
func syntaxOffset(err error) (int64, bool) {
var se *json.SyntaxError
if !errors.As(err, &se) {
return 0, false
}
return se.Offset, true
}
Тут важлива звичка: ми не порівнюємо текст err.Error(), а дивимося на тип. Це надійніше й не ламається, коли змінюються формулювання помилок.
5. Offset, рядок і колонка
Одне — сказати «позиція 183», інше — «рядок 12, колонка 5». Людині рядок і колонка зрозуміліші. Але є нюанс: Offset — це позиція в байтах, а щоб порахувати рядок і колонку, потрібно мати вихідний текст цілком.
Якщо ви читаєте невеликий конфіг або невеликий JSON-файл, можна дозволити собі прочитати все в памʼять, наприклад через io.ReadAll, саме заради хорошої діагностики. Це нормальний компроміс: зручність користувача часто важливіша за мікрооптимізації.
Мініфункція для line/col за байтовим зрізом:
package main
func lineCol(data []byte, offset int64) (line, col int) {
line, col = 1, 1
for i := int64(0); i < offset-1 && i < int64(len(data)); i++ {
if data[i] == '\n' {
line, col = line+1, 1
} else {
col++
}
}
return line, col
}
Так, це не враховує руни й UTF-8 «по-справжньому», але для діагностики JSON за байтами цього зазвичай достатньо: помилки encoding/json теж рахують зміщення в байтах.
6. Вбудовуємо це в застосунок: суворий decode TaskFile
Тепер давайте привʼяжемо все до нашого навчального застосунку — простого менеджера задач. Нехай у нас є формат «файл задач», де ми зберігаємо версію та список задач.
package main
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type TaskFile struct {
Version int `json:"version"`
Tasks []Task `json:"tasks"`
}
Ми читаємо один JSON-документ у строгому режимі й у разі помилки додаємо контекст операції, а потім на межі перетворюємо її на *InputError.
Спочатку — суворий decode і контекст:
package main
import (
"encoding/json"
"fmt"
"io"
)
func decodeTaskFile(r io.Reader) (TaskFile, error) {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var tf TaskFile
if err := dec.Decode(&tf); err != nil {
return TaskFile{}, fmt.Errorf("розбір файлу задач: %w", err)
}
return tf, nil
}
Тепер — «межа застосунку»: умовний main, де ми вирішуємо, що показувати користувачеві.
package main
import (
"fmt"
"os"
)
func main() {
_, err := decodeTaskFile(os.Stdin)
if err != nil {
err = wrapJSONInput(err)
fmt.Fprintln(os.Stderr, "Помилка:", err) // повідомлення для користувача
os.Exit(1)
}
fmt.Println("OK") // усе гаразд
}
Сенс у тому, що decodeTaskFile не намагається бути «дружнім». Його робота — коректно читати дані й зберігати причину помилки. А дружність — це робота межі застосунку, тобто CLI. Такий підхід дуже схожий на ідею: показуємо користувачеві просте повідомлення, а деталі залишаємо для діагностики.
7. Типові помилки під час оформлення JSON-помилок
Помилка №1: повертати «голу» помилку без контексту.
Якщо ви просто повертаєте err, то на верхньому рівні бачите unexpected EOF і починаєте грати в детектива: EOF де, чому, на якому кроці? Набагато краще додавати контекст на кожному шарі: "розбір файлу задач", "читання конфігурації", "unmarshal versioned envelope". Так рядок помилки стає історією дій, а не загадкою.
Помилка №2: використовувати "%v" замість "%w" там, де ви хочете розпізнавати причину.
Фраза "decode task file: …" однаково виглядатиме і з "%v", і з "%w", і це підступно. Але лише "%w" збереже причину так, щоб вище можна було зробити errors.As(err, &se) і дістати Offset у *json.SyntaxError. Wrapping — це не косметика, а механізм.
Помилка №3: класифікувати помилки за err.Error() і підрядками, коли є тип.
Рядки помилок можуть змінюватися, локалізуватися, доповнюватися. Якщо ви можете розпізнати *json.SyntaxError або *json.UnmarshalTypeError через errors.As, робіть так — це набагато надійніше. І сама ідея «помилка — значення, його можна аналізувати» в Go вважається фундаментальною.
Помилка №4: показувати користувачеві надто технічні деталі.
Повідомлення cannot unmarshal number into Go struct field Task.id of type string — дуже інформативне для розробника, але для користувача це шум і цілком легальна причина почати ненавидіти вашу програму. Краще показувати «поле id має бути числом», а технічну причину зберігати всередині Unwrap(), щоб ви могли дістати її під час налагодження.
Помилка №5: не виділяти випадок порожнього введення (io.EOF).
io.EOF часто означає «введення порожнє», а не «у нас щось зламалося». Якщо ваша функція за контрактом очікує JSON, порожнє введення — це зрозуміла помилка користувача або файлу. Якщо цей кейс не виділити, користувач отримає дивне EOF і не зрозуміє, що сталося.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ