1. Навіщо взагалі думати про відповідність JSON ↔ Go-типів
Коли ви вперше бачите JSON, виникає спокуса сприймати його як «рядок із дужками». У маленьких прикладах це навіть працює. Але щойно JSON стає вхідними або вихідними даними вашого застосунку — файлом, мережею чи API — він перетворюється на контракт: форма даних має бути зрозумілою й стабільною, інакше програма зламається в найменш слушний момент. Зазвичай — просто на демо.
У Go є стандартний пакет encoding/json, який уміє перетворювати JSON на значення Go й назад. Але ключова думка така: один і той самий JSON можна «прочитати» різними Go-типами, і від обраного типу залежить, наскільки ваш код буде безпечним, зрозумілим і зручним для подальшої логіки.
Щоб не писати застосунок у стилі «а давайте все буде any, а там розберемося», сьогодні побудуємо «карту місцевості»: які форми JSON найкраще відповідають struct, []T і map[string]any, а також чим ці варіанти відрізняються.
JSON — це шість типів
Новачкам часто здається, що JSON — це «все підряд» і там може бути що завгодно. Насправді JSON дуже строгий: він складається лише з шести типів. Це приємна новина, бо якщо типів мало, то й правил зіставлення з Go-типами теж небагато. Решта — лише комбінації.
Ось компактна таблиця відповідностей. Вона не єдина можлива, але для старту — найпрактичніша:
| JSON-тип | Приклад | Типовий Go-тип, коли форма відома | Типовий Go-тип, коли форма невідома |
|---|---|---|---|
| object | |
|
|
| array | |
|
|
| string | |
|
|
| number | |
|
зазвичай це усередині |
| boolean | |
|
|
| null | |
часто |
|
Насамперед нас цікавитимуть три «контейнерні» випадки: JSON object → struct або map, JSON array → []T. Саме вони визначають архітектуру читання даних: житимете ви у світі типів чи у світі перевірок на кожному кроці.
2. JSON object → struct: коли форма даних відома
Коли JSON-об’єкт має зрозумілу структуру, наприклад задачу в трекері з полями id, title і done, найрозумніше читати його в struct. Це схоже на анкету: у ній поля відомі наперед. Якщо почати зберігати її як «мішок із ключів і значень», ви лише ускладните собі життя. struct дає компілятору шанс захистити вас від помилок.
Важливо пам’ятати про одну особливість рефлексії в Go: як правило, пакети кодування й декодування працюють лише з експортованими полями структури, тобто з тими, що починаються з великої літери. Цю ж ідею ви побачите й в інших стандартних пакетах серіалізації; наприклад, в описі encoding/gob окремо підкреслюється, що кодуються й декодуються лише експортовані поля. Для JSON діє те саме правило.
Почнімо розвивати наш навчальний застосунок «міні-трекер задач». Поки що без файлів і без HTTP: просто вчимося правильно представляти задачу в коді.
Міні-модель Task і читання JSON у структуру
Створімо структуру й спробуймо розпарсити вхідний JSON. Зверніть увагу: JSON пише id, title, done, а в Go ми використовуємо ID, Title, Done. Для базових випадків encoding/json уміє зіставляти імена досить гнучко, але зі складнішими назвами, на кшталт created_at, доведеться розбиратися окремо.
package main
type Task struct {
ID int
Title string
Done bool
}
Тепер функція, яка читає JSON у Task. Тут важлива ідея: у json.Unmarshal потрібно передавати змінну за адресою, бо бібліотеці треба кудись записати результат.
package main
import (
"encoding/json"
"fmt"
)
func parseTask(data []byte) (Task, error) {
var t Task
if err := json.Unmarshal(data, &t); err != nil {
return Task{}, fmt.Errorf("parse task: %v", err)
}
return t, nil
}
І маленький приклад використання:
package main
import "fmt"
func main() {
t, err := parseTask([]byte(`{"id":1,"title":"read docs","done":false}`))
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", t) // {ID:1 Title:read docs Done:false}
}
Зверніть увагу на відчуття: після Unmarshal у вас на руках нормальна Go-структура, з якою можна працювати без «танців» навколо типів.
Вкладені об’єкти: struct усередині struct
У реальному JSON об’єкти часто бувають вкладеними: наприклад, у задачі може бути автор. У Go це зручно моделювати вкладеною структурою.
package main
type Author struct {
Name string
}
type Task struct {
ID int
Title string
Done bool
Author Author
}
Якщо вхідні дані такі:
{"id":2,"title":"write code","done":true,"author":{"name":"Sam"}}
то після Unmarshal ви отримаєте Task{Author: Author{Name:"Sam"}}. Це одна з причин, чому struct — ваш найкращий друг, коли структура вхідних даних відома.
Часткове читання: забрати лише потрібні поля
Іноді потрібна лише частина даних. Наприклад, ви хочете швидко перевірити, що id є, а решта поки неважлива. Тоді можна зробити структуру-приймач із мінімальним набором полів.
package main
import "encoding/json"
type TaskIDOnly struct {
ID int
}
func parseTaskID(data []byte) (int, error) {
var x TaskIDOnly
if err := json.Unmarshal(data, &x); err != nil {
return 0, err
}
return x.ID, nil
}
Це дуже практичний прийом: маленька структура під конкретну задачу робить код читабельнішим, ніж читання всього в map[string]any і ручні перевірки.
3. JSON array → []T: коли даних багато й вони однотипні
JSON-масив — це послідовність елементів. У Go природна форма для послідовності — зріз []T. І тут правило просте: ви обираєте тип T — і тим самим задаєте, як має читатися кожен елемент масиву. Якщо T — структура, то кожен елемент масиву має бути JSON-об’єктом відповідної форми.
Це схоже на коробку з однаковими деталями: якщо ви очікуєте «болти M6», то не хочете, щоб раптом там опинилася «бананова шкірка» — хоча JSON теоретично може підсунути й таке. З []T ви просите декодер перевірити форму за вас.
Читаємо масив задач: []Task
Припустімо, ми отримали список задач:
[
{"id":1,"title":"read docs","done":false},
{"id":2,"title":"write code","done":true}
]
У Go це читається як []Task:
package main
import (
"encoding/json"
"fmt"
)
func parseTaskList(data []byte) ([]Task, error) {
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, fmt.Errorf("parse task list: %v", err)
}
return tasks, nil
}
І використання:
package main
import "fmt"
func main() {
data := []byte(`[{"id":1,"title":"A","done":false},{"id":2,"title":"B","done":true}]`)
tasks, err := parseTaskList(data)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(len(tasks)) // 2
}
Масив простих значень: []string, []int
Якщо JSON-масив складається з рядків, його зручно читати в []string. Наприклад, теги задачі:
["go","json","practice"]
package main
import "encoding/json"
func parseTags(data []byte) ([]string, error) {
var tags []string
err := json.Unmarshal(data, &tags)
return tags, err
}
Цей приклад виглядає майже надто простим, але в цьому й перевага: правильний вибір типу робить код коротшим, а помилки — помітнішими раніше.
nil-зріз і порожній зріз
У зрізів є два «порожні» стани: nil і []T{} — порожній, але не nil. У повсякденній логіці це зазвичай неважливо, але під час серіалізації та порівняння різниця може проявлятися. Новачкові достатньо пам’ятати: якщо функція повертає []T, часто зручно повертати nil і err у разі помилки, а в разі успіху — нормальний зріз, можливо порожній.
Якщо ви лише починаєте, не намагайтеся вигадувати складні правила. Важливо лише послідовно дотримуватися обраного контракту: або «порожній список = порожній зріз», або «порожній список може бути nil». Головне — не змішувати: в одному місці так, в іншому — інакше.
4. JSON object → map[string]any: коли форма невідома
Іноді структура JSON заздалегідь невідома. Наприклад, ви робите налагоджувальну утиліту, яка приймає «будь-який JSON» і друкує, що всередині. Або пишете код, який має пропускати крізь себе «користувацькі поля», не фіксуючи список ключів наперед.
Тоді ви обираєте map[string]any (у старих версіях часто писали map[string]interface{} — за змістом це те саме). Але за гнучкість ви платите: усередині з’являються значення типу any, а це означає, що далі доведеться вручну робити перевірки й приводити типи.
І ще один залізний факт: ключі в JSON-об’єкті — завжди рядки, тому map[int]... тут не підходить за визначенням.
Декодуємо JSON у map[string]any і дивимося типи
Візьмемо JSON:
{"id":1,"title":"A","done":true}
Декодуємо:
package main
import (
"encoding/json"
"fmt"
)
func main() {
var m map[string]any
_ = json.Unmarshal([]byte(`{"id":1,"title":"A","done":true}`), &m)
fmt.Printf("%T\n", m["title"]) // string
fmt.Printf("%T\n", m["done"]) // bool
fmt.Printf("%T\n", m["id"]) // float64
}
Неочікувано: id став float64. Це не помилка й не «знущання над студентами», а наслідок того, що JSON-число саме по собі не вказує, це int чи float. Коли ви декодуєте в «динамічну» структуру (any), пакет обирає стандартне представлення числа — float64.
Тому правило просте: map[string]any годиться, коли ви готові жити у світі ручних перевірок.
Дістаємо значення з map[string]any безпечно
Якщо ви все ж обрали цей шлях, діставайте значення обережно: спершу перевірте, чи є ключ, а потім — тип.
package main
import "fmt"
func getTitle(m map[string]any) (string, error) {
v, ok := m["title"]
if !ok {
return "", fmt.Errorf("title is missing")
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("title must be string")
}
return s, nil
}
Так, це довше, ніж t.Title у структурі. Зате це чесно: ви явно формулюєте правила вхідних даних і отримуєте зрозумілі помилки.
Вкладені об’єкти й масиви в map[string]any
Якщо JSON містить вкладені об’єкти та масиви, у динамічному вигляді ви отримаєте рекурсивну структуру з:
- map[string]any для об’єктів,
- []any для масивів,
- string, bool, float64, nil для примітивів.
У певному сенсі це «дерево з вузлами any», а ваш код перетворюється на обхід дерева з купою перевірок. Це нормально для налагоджувальних і «універсальних» інструментів, але зазвичай не те, що хочеться бачити в бізнес-логіці.
5. Як обрати: struct чи map, і де тут місце []T
У реальному проєкті вибір типу — це не «смак», а інженерне рішення. Вам важливо розуміти, чого ви хочете: строгості й читабельності чи гнучкості з принципом «на вході приймемо що завгодно».
Уявіть, що JSON — це посилка, а Go-тип — форма приймання на складі. Якщо ви знаєте, що вам привозять смартфони, ви робите форму з полями «серійний номер», «модель» і «колір». Якщо не знаєте, що саме привезуть, ви пишете «прийнято: коробка, вміст уточнюється» — а далі розбиратися значно складніше.
Ось зручна порівняльна таблиця:
| Критерій | |
|
|---|---|---|
| Типобезпечність | висока | низька: усе через перевірки |
| Читабельність коду | зазвичай чудова | швидко стає шумною |
| Гнучкість щодо ключів | форма фіксована | можна будь-які ключі |
| Коли помилки виявляються | часто раніше | пізніше, під час виконання |
| Ідеально для | API-контрактів, DTO, моделей | налагодження, «плагінних» даних, невідомої схеми |
А []T — це просто багато однакових сутностей. Тому найчастіше ви обираєте або []Task, або []map[string]any, залежно від того, чи відома форма елемента.
Помилки декодування: некоректний JSON — це нормально
Поки ви навчаєтеся, дуже хочеться написати _ = json.Unmarshal(...) і «не думати про погане». Але зовнішній JSON майже завжди може виявитися неправильним: користувач помилився, файл обрізався, мережа чхнула, або хтось надіслав вам не JSON, а любовного листа у форматі «майже JSON».
У encoding/json є конкретні типи помилок для деяких випадків. Наприклад, синтаксичні проблеми парсингу виражаються помилкою типу json.SyntaxError, і в неї є поле Offset, де вказано місце в байтах, де саме все зламалося. Цю ідею — і сам тип — часто використовують, щоб робити повідомлення про помилки змістовнішими.
Нам тут не потрібно заглиблюватися в обчислення line/column — важливо лише запам’ятати звичку: помилка декодування — це не «ганьба», а штатна гілка коду. Ви перевіряєте err і дієте далі: або повідомляєте користувачу, або припиняєте обробку.
6. Типові помилки під час вибору й використання Go-типів для JSON
Помилка № 1: читати відомий JSON-об’єкт у map[string]any «про всяк випадок».
Таке рішення здається гнучким, але на практиці швидко перетворює код на болото з перевірок, приведень типів і незрозумілих помилок. Якщо структура відома й стабільна, struct майже завжди простіший, коротший і надійніший: поля вже типізовані, і компілятор допомагає вам не переплутати string із bool.
Помилка № 2: очікувати, що map[string]any збереже числа як int.
Під час динамічного декодування числа зазвичай стають float64, і це ламає наївне припущення на кшталт m["id"].(int). Треба або читати в структуру з int, або свідомо приводити float64 → int з перевірками (і пам’ятати, що 3.14 у id — це вже привід насторожитися).
Помилка № 3: використовувати map[int]any для JSON object.
У JSON ключі об’єкта — рядки. Завжди. Навіть якщо «візуально» ключ виглядає як число ({"1":"a"}), це все одно рядок "1". Тому правильний контейнер для об’єкта — map[string]....
Помилка № 4: забути передати адресу в Unmarshal.
json.Unmarshal(data, t) не зможе заповнити t, бо бібліотеці потрібне місце, куди записати результат. Правильно: json.Unmarshal(data, &t). Якщо ви бачите помилку або дивну поведінку, насамперед перевірте, чи не забули &.
Помилка № 5: зробити поля структури неекспортованими й дивуватися, чому вони не заповнюються.
Якщо ви написали type Task struct { title string }, то під час декодування поле title не заповнюватиметься, бо воно неекспортоване. У підсумку в вас буде «вічно порожній title», і ви почнете підозрювати змову JSON. Насправді достатньо зробити Title string, а приховувати поля потрібно свідомо й за правилами моделі, а не випадково.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ