Що мовчки видаляє JSON.stringify, що перетворює та що відмовляється серіалізувати
Дізнайтеся, які значення JavaScript ігноруються або змінюються функцією JSON.stringify, як toJSON, замінники та функції відновлення це виправляють, та коли structuredClone є кращим інструментом.
JSON.stringify рідко створює проблеми. Коли він натрапляє на значення, яке не може представити, зазвичай він його пропускає або перетворює на щось інше та продовжує роботу, повертаючи абсолютно коректний JSON. Саме так з’являються баги, які знаходяться на кілька кроків від своєї причини: дані зникають під час серіалізації, але проблема проявляється пізніше в коді, який навіть не усвідомлює, що взагалі відбувалася серіалізація. У цьому посібнику описано, що загубляється, що змінює тип та що спричиняє помилки, а потім наведені вбудовані інструменти (toJSON, замінники, функції відновлення та structuredClone), які дозволяють свідомо керувати кожним випадком.
Властивості, які зникають без сліду
Розглянемо об’єкт сесії, який містить суміш звичайних даних, функцію-колбек, явно визначене поле типу undefined та символ:
const session = {
userId: 42,
role: "admin",
onExpire: () => console.log("session expired"),
lastActivity: undefined,
tempToken: Symbol("temp"),
};
console.log(JSON.stringify(session));
// {"userId":42,"role":"admin"}
Результат містить лише дві з п’яти властивостей. onExpire, lastActivity та tempToken зникли без жодних винятків, попереджень чи ознак у отриманому рядку, які б вказували на їхнє видалення. Усередині об’єкта функція JSON.stringify ігнорує будь-яку властивість, значення якої є undefined, функцією чи Symbol. Жоден з цих типів даних не має представлення у форматі JSON, тому замість того, щоб завершити роботу з помилкою, серіалізатор повертає коректний JSON для тих даних, що залишилися.
Наслідок є непомітним. Користувач, який обробляє цей рядок та очікує наявності lastActivity, навіть якщо його значення буде undefined, не знайде його. Ключ не є порожнім; він узагалі ніколи не потрапляв до результату обробки. Код, який розрізняє стан „відсутній“ та „наявний, але undefined“, наприклад за допомогою "lastActivity" in obj або Object.keys, тепер буде поводитися інакше з іншого боку.
Масиви поводяться інакше
Ті самі непідтримувані значення отримують різне оброблення всередині масиву:
console.log(JSON.stringify([undefined, function () {}, 1]));
// [null,null,1]
Тут вони стають null, замість того щоб зникнути. Масив не може видалити елемент, не змінивши індекси всіх наступних елементів, тому серіалізатор зберігає це місце та заповнює його найближчим еквівалентом „нічого“ у JSON. Основна причина залишається однаковою, проте залежно від того, чи знаходилася цінність у об’єкті чи в масиві, спостерігаються два різних беззвучні поведінки. Два пов’язані крайні випадки також дотримуються цього принципу: NaN та Infinity серіалізуються як null, а виклик JSON.stringify безпосередньо на undefined або функції повертає undefined, а не рядок.
Дати повертаються у вигляді рядків
Здається, що дати виживають після обробки, але лише наполовину:
const record = { createdAt: new Date() };
const json = JSON.stringify(record);
console.log(json); // {"createdAt":"2026-09-07T14:30:00.000Z"}
const restored = JSON.parse(json);
console.log(restored.createdAt instanceof Date); // false
console.log(typeof restored.createdAt); // "string"
Сама інформація про дату залишається недоторканою; вона знаходиться прямо у результаті у вигляді рядка ISO 8601. Втрачається лише тип даних. Функція JSON.parse не може зрозуміти, що певний рядок колись був об’єктом типу Date, а не просто текстом, який на вигляд схожий на нього, тому вона повертає рядок, і він залишається рядком, доки щось не перетворить його назад.
Це має значення, як тільки код викликає метод, пов’язаний із датою, після цього процесу. Наприклад, record.createdAt.getFullYear() викидає помилку не через неправильні дані, а тому, що його тип тихо змінився протягом обробки. Це часто трапляється з відповідями API, значеннями, відновленими з localStorage, та повідомленнями, переданими через черги.
Циклічні структури також викликають помилки
Не кожна проблема проявляється беззвучно. Деякі об’єкти взагалі не можуть бути серіалізовані, і движок чітко це показує:
const parent = { name: "parent" };
const child = { name: "child", parent };
parent.child = child;
JSON.stringify(parent); // TypeError: Converting circular structure to JSON
Щоб сформувати свій результат, JSON.stringify пройшовся по графу об’єктів. Коли якийсь об’єкт у цьому графі веде назад до одного з його предків, цей процес ніколи не закінчиться. Замість того, щоб безкінечно рекурсувати, двигун помічає цей цикл та негайно кидає TypeError. Це один із небагатьох випадків, коли серіалізатор чесно зазнає невдачі, і це має вагому причину: не існує часткової чи приблизної відповіді, оскільки циклічний граф справді не може бути перетворений на дерево JSON. Значення BigInt є ще одним яскравим прикладом; вони також кидають TypeError, якщо тільки ви самі їх не перетворите.
Контроль результату за допомогою toJSON
Деякі вбудовані типи вже визначають, як вони мають виглядати у форматі JSON, тому об’єкт Date перетворюється на рядок у форматі ISO, а не на порожній об’єкт. Будь-який об’єкт може наслідувати таку поведінку, визначивши метод toJSON. Якщо такий метод існує, JSON.stringify викликає його та серіалізує значення, яке він повертає, замість власних властивостей об’єкта:
class Money {
constructor(cents) {
this.cents = cents;
}
toJSON() {
return { amount: this.cents / 100, currency: "USD" };
}
}
const price = new Money(3499);
console.log(JSON.stringify({ price })); // {"price":{"amount":34.99,"currency":"USD"}}
Без методу toJSON екземпляр Money був би записаний у своїй внутрішній формі {"cents":3499}, що призводить до витоку деталей реалізації, на які інші частини системи ніколи не повинні були покладатися. За наявності цього методу об’єкт сам визначає свою публічну форму представлення. Date використовує саме цей механізм: Date.prototype.toJSON є тим, хто створює рядок у форматі ISO.
Пам’ятайте, що цей процес є однонапрямковим. Аналіз результату надає вам звичайний об’єкт із полями amount та currency, а не екземпляр класу Money; відновлення цього класу — завдання функції відновлення, описаної далі.
Функції заміни та відновлення: формування даних у обох напрямках
JSON.stringify приймає необов’язковий другий аргумент — функцію заміни, яка викликається для кожної пари ключ-значення перед їх записом. Повернення значення undefined призводить до видалення цієї пари, а будь-яке інше значення замінює початкове. Це є ефективним способом фільтрації конфіденційних полів під час експорту:
const user = { id: 1, name: "Priya", passwordHash: "a1b2c3..." };
const safe = JSON.stringify(user, (key, value) => {
return key === "passwordHash" ? undefined : value;
});
console.log(safe); // {"id":1,"name":"Priya"}
JSON.parse пропонує аналогічний механізм — функцію відновлення, яка викликається для кожної пари ключ-значення після обробки даних. Це є офіційним рішенням проблеми з типом Date, про яку йшлося раніше:
const restored = JSON.parse(json, (key, value) => {
if (key === "createdAt") return new Date(value);
return value;
});
console.log(restored.createdAt instanceof Date); // true
Є два моменти, які варто знати. Функції відновлення працюють знизу догори, тому вкладені значення вже відновлюються під час обробки їхнього батьківського елемента. Крім того, перевірка самої назви ключа дозволяє знайти цей ключ на будь-якій глибині, тому поле createdAt, розташоване всередині якогось вкладеного об’єкта, також буде перетворено; якщо це не те, чого ви хочете, перевірте також формат значення.
Жоден з цих моментів не є складним аспектом API. Це саме ті способи, які призначені для точного керування серіалізацією, і вони набагато надійніші, ніж видалення властивостей перед їх перетворенням у рядок чи ручна корекція об’єктів після парсингу.
Map та Set втрачають усе
Розробники, які очікують, що JSON збереже будь-який тип об’єкта, часто стикаються з проблемами саме тут:
const tags = new Set(["urgent", "billing"]);
console.log(JSON.stringify({ tags })); // {"tags":{}}
Для серіалізатора Set не є ні масивом, ні звичайним об’єктом із перелічуваними власними властивостями, тому він перетворюється на порожній об’єкт, і кожен елемент, який він містив, беззвучно видаляється. Map потрапляє у таку саму ситуацію. Якщо хоча б один з цих об’єктів має залишитися, його потрібно явно перетворити перед серіалізацією, наприклад, розгорнувши його у масив:
const json = JSON.stringify({ tags: [...tags] }); // convert Set to array before stringifying
Для Map оператори [...map] або Object.fromEntries(map) створюють форми, які можна серіалізувати, і функція відновлення зможе відновити початковий колекційний об’єкт під час ресторіалізації.
Глибокі копії: використовуйте structuredClone
Протягом багатьох років JSON.parse(JSON.stringify(obj)) був поширеним способом глибокого клонування об’єкта. Він працює лише випадково та успадковує всі вищезгадані обмеження: функції та значення undefined зникають, дати перетворюються на рядки, колекції спорожніють, а циклічні посилання спричиняють помилки.
Сучасні браузери та Node.js пропонують спеціально створену альтернативу:
const clone = structuredClone(original);
structuredClone виконує справжню глибоку копію за допомогою алгоритму структурованої копіювання. Він зберігає об’єкти типу Date, Map та Set, а також ефективно обробляє циклічні посилання, що JSON не може ні правильно обробити, ні прийняти. Проте у нього є свої обмеження: він кидає помилку DataCloneError для функцій та елементів DOM, а інстанції класів повертаються у вигляді звичайних об’єктів без їхнього прототипу. Якщо метою є просто копіювання даних, structuredClone майже завжди є кращим варіантом. JSON.stringify ніколи не був створений для копіювання; він просто був настільки зручним, що люди почали використовувати його для цієї мети.
Основні висновки
JSON.stringifyсеріалізує лише ту частину значень JavaScript, яку може представити JSON, а не довільні значення JavaScript.
undefined, функції та символи видаляються; у масивах вони стають null.BigInt спричиняють помилки; Map та Set без звуків серіалізуються у {}.toJSON для визначення публічної структури об’єкта, замінювач для фільтрації результату та відроджувач для відновлення типів.structuredClone, коли потрібна копія, а JSON.stringify розглядайте як фільтр, що форматує дані, пропуски в якому потрібно обробляти явно, щоб поля, колекції та типи не зникали непомітно між різними частинами вашої системи.Пов’язана література
- Понад розмір пакету: як з’ясувати, що насправді уповільнює ваш веб-додаток — Чому скорочення кілобайтів рідко допомагає вирішити проблему повільності додатку, та як відстежувати справжній час очікування між серверами, етапами обробки даних, скриптами сторонніх постачальників та зображеннями.
- Що насправді гарантує async/await та що залишає без уваги — Які процеси насправді призупиняються завдяки await, як уникати серіалізованих запитів, та чому для вирішення проблем з помилками, скасуваннями, порядком виконання та повторними спробами потрібні підходи, що виходять за межі async/await.