vyvchy
    Теми розділу

    08 · JavaScript/TypeScript для AQA

    Об'єкти, масиви та методи роботи з даними

    Зміст

    Робота AQA — це переважно робота з даними. Відповідь API прилітає обʼєктом, тестові дані лежать масивом фікстур, локатор віддає колекцію елементів, а перевірка зводиться до «дістань з цієї структури потрібне поле й порівняй». Обʼєкти, масиви та методи роботи з ними — це той інструмент, яким ти щодня розбираєш і збираєш дані у тестовому коді. Не знаєш його — пишеш цикли на пів екрана там, де вистачило б одного filter, або, гірше, ловиш плаваючий баг, бо «скопіював» обʼєкт, а насправді лишив на нього посилання.

    Саме тут ховається найпідступніше питання цієї глави, яке обожнюють на співбесідах: чому {} !== {} і чому «копія» масиву раптом змінює оригінал. Відповідь — у посилальній семантиці (reference semantics), і без неї решта методів залишаються магією. Тож почнемо з механізму, а не з переліку функцій.

    Значення vs посилання: чому {} не дорівнює {}

    У JavaScript є два світи. Примітиви (primitives) — рядки, числа, boolean, null, undefined, symbol, bigint — зберігаються й копіюються за значенням. Обʼєкти ж (а масив, функція, Date, Map — усе це обʼєкти) зберігаються за посиланням (by reference): змінна тримає не сам обʼєкт, а «адресу», за якою він лежить у памʼяті.

    Звідси все й випливає. Кожен літерал {} чи [] створює новий обʼєкт за новою адресою. Оператори === і !== для обʼєктів порівнюють не вміст, а посилання — чи це фізично той самий обʼєкт.

    const a = { id: 1 };
    const b = { id: 1 };
    const c = a;
    
    a === b; // false — різні обʼєкти, хоч вміст однаковий
    a === c; // true  — c тримає те саме посилання, що й a

    a

    обʼєкт #1: { id: 1 }

    c = a

    b

    обʼєкт #2: { id: 1 }

    a

    обʼєкт #1: { id: 1 }

    c = a

    b

    обʼєкт #2: { id: 1 }

    Практичний наслідок для тестів: обʼєкти й масиви не можна порівнювати через === чи toBe. expect(actual).toBe(expected) для двох різних обʼєктів завжди впаде, навіть якщо вони ідентичні. Для порівняння за вмістом існують toEqual (Playwright, Jest) або глибокі порівняння в бібліотеках асертів. І навпаки: якщо дві змінні тримають одне посилання, зміна через одну «магічно» видно через іншу — це не баг, це та сама причина.

    Глибше про передачу за значенням vs за посиланням і про == vs === — у главі Змінні, типи даних і приведення типів.

    Безпечний доступ: ?. і ??

    Відповідь API рідко буває ідеальною: поле може бути null, вкладений обʼєкт — відсутнім, масив — порожнім. Спроба дістати user.profile.avatar.url, коли profile дорівнює null, валить тест не асертом, а падінням: Cannot read properties of null. Класичний захист «якщо є, то візьми» роздувався у драбину перевірок user && user.profile && user.profile.avatar.

    Optional chaining (?.) розвʼязує це одним оператором. Якщо ліворуч від ?. стоїть null або undefined — весь вираз коротко замикається (short-circuit) й повертає undefined, не намагаючись читати далі.

    const url = response.user?.profile?.avatar?.url; // undefined, а не падіння
    response.items?.[0];       // безпечний доступ до елемента масиву
    response.onLoad?.();       // виклик методу, лише якщо він існує

    Ключова тонкість: ?. реагує саме на null/undefined, а не на «порожнє». 0, порожній рядок, false — валідні значення, ланцюжок їх пропускає далі.

    Поруч працює nullish coalescing (??) — «підстав дефолт, якщо ліворуч null або undefined». Його часто плутають з ||, і це джерело реальних багів. || спрацьовує на будь-яке хибне (falsy) значення, а до них належать і 0, і '', і false:

    const timeout = config.timeout ?? 30_000;  // 0 лишається 0
    const timeoutBug = config.timeout || 30_000; // 0 підміниться на 30_000 — баг

    Якщо config.timeout дорівнює 0 (миттєвий таймаут — цілком валідне налаштування), || мовчки замінить його на 30 секунд. ?? цього не робить, бо 0 — не null і не undefined. Правило: для дефолтів, де 0/''/false — легітимні значення, бери ??, а не ||.

    Деструктуризація

    Деструктуризація (destructuring) — це розпакування полів обʼєкта чи елементів масиву в окремі змінні одним рядком. Вона не робить нічого нового щодо семантики, лише скорочує шум і робить наміри явними.

    const { status, body } = await request.get('/api/users');
    const { id, name = 'anonymous' } = body;       // дефолт, якщо name === undefined
    const { profile: { city } } = body;            // вкладена
    const [first, second] = await Promise.all([a, b]); // масивна, за позицією

    Найчастіше AQA бачить її у сигнатурах фікстур Playwright: async ({ page, request }) => {} — це деструктуризація обʼєкта фікстур у параметрі функції. Дефолт = 'anonymous' підставляється лише коли значення суворо undefined (не null, не 0). Перейменування profile: { city } дістає вкладене поле й одразу дає йому імʼя. Механіку параметрів функцій розбираємо у главі Функції, замикання та область видимості.

    Spread і rest: копіювання й збирання

    Три крапки ... роблять дві протилежні речі залежно від контексту.

    Spread — «розсип» елементи існуючої структури в нову. Це основний спосіб зробити копію масиву чи обʼєкта й найпоширеніший спосіб зібрати нову структуру з існуючих:

    const copy = [...items];                 // нова копія масиву
    const merged = { ...defaults, ...overrides }; // злиття; праве перекриває ліве
    const withExtra = [...base, newItem];    // base не мутується

    Rest — навпаки, «збери решту» в один масив чи обʼєкт при деструктуризації або в параметрах функції:

    const [head, ...tail] = items;           // head — перший, tail — масив решти
    const { id, ...rest } = user;            // rest — обʼєкт без поля id
    function log(...args: unknown[]) {}       // будь-яка кількість аргументів

    Критичне застереження, яке веде нас до наступної теми: spread робить поверхневу копію. Верхній рівень новий, а вкладені обʼєкти — ті самі за посиланням.

    Методи масивів: map / filter / reduce / find / some / every

    Це серце обробки даних і найчастіша тема live coding. Усі шість приймають функцію-колбек і не мутують вихідний масив — вони повертають нове значення (reduce згортає масив в одне значення, решта — новий масив, елемент або boolean). Це робить їх безпечними, а map/filter — ще й ланцюжковими.

    МетодЩо повертаєНавіщо
    mapновий масив тієї ж довжиниперетворити кожен елемент
    filterновий масив ⩽ довжинилишити елементи, що проходять умову
    reduceодне значеннязгорнути масив у число/обʼєкт/рядок
    findперший елемент або undefinedзнайти один за умовою
    somebooleanчи є хоч один, що проходить умову
    everybooleanчи всі проходять умову

    На реальних тестових даних це виглядає так:

    const users = [
      { name: 'Ann', role: 'admin', active: true },
      { name: 'Bob', role: 'user', active: false },
      { name: 'Cid', role: 'user', active: true },
    ];
    
    const activeNames = users.filter((u) => u.active).map((u) => u.name); // ['Ann', 'Cid']
    const hasAdmin = users.some((u) => u.role === 'admin');               // true
    const allActive = users.every((u) => u.active);                       // false
    const bob = users.find((u) => u.name === 'Bob');                      // обʼєкт Bob
    const total = users.reduce((acc, u) => acc + (u.active ? 1 : 0), 0);  // 2

    reduce лякає новачків, але механізм простий: акумулятор (acc) — це «підсумок, що переноситься з кроку на крок», а другий аргумент (0) — його початкове значення. Ним будують і числа, і згруповані обʼєкти (наприклад, розкласти масив за роллю в обʼєкт role → список). Важлива пастка: reduce без початкового значення на порожньому масиві кидає помилку — завжди задавай початок явно.

    Ці методи — те, чим AQA щодня перетворює відповідь API в очікуваний для асерту вигляд: витягнути id усіх елементів (map), відкинути видалені (filter), перевірити, що всі повернуті записи належать одному користувачу (every).

    sort і мутація: тиха пастка

    А ось sort поводиться інакше за попередні методи, і на цьому провалюються навіть досвідчені. sort мутує масив на місці й повертає посилання на той самий масив, а не на копію.

    const original = [3, 1, 2];
    const sorted = original.sort();
    sorted === original; // true — це один масив; original теж уже [1, 2, 3]

    Друга пастка — компаратор за замовчуванням. Без функції порівняння sort перетворює елементи на рядки й сортує лексикографічно. Тому числа сортуються неправильно:

    [1, 2, 10, 21].sort();            // [1, 10, 2, 21] — рядкове порівняння!
    [1, 2, 10, 21].sort((a, b) => a - b); // [1, 2, 10, 21] — правильно

    Компаратор має повертати відʼємне число (a перед b), додатне (b перед a) або нуль. Для чисел це a - b; для рядків — localeCompare, який ще й коректно впорається з українськими літерами.

    Загальне правило корисно тримати в голові — які методи мутують масив, а які повертають новий:

    Мутують оригіналПовертають нове (безпечні)
    sort, reverse, splice, push, pop, shift, fillmap, filter, slice, concat, flat, toSorted

    Примітка: toSorted, toReversed, toSpliced, with — немутуючі версії, доступні в сучасних середовищах (Node 20+). Якщо треба відсортувати, не чіпаючи джерело, у старіших середовищах роби копію перед сортуванням: [...original].sort(...).

    Set і Map

    Обʼєкт і масив — не єдині колекції. Set і Map розвʼязують задачі, де вони незручні.

    Set — колекція унікальних значень. Найчастіше AQA бере його для дедуплікації й перевірки унікальності (класика: чи немає дублікатів id у відповіді):

    const ids = users.map((u) => u.id);
    const unique = [...new Set(ids)];        // дублікати прибрано
    unique.length === ids.length;            // true, якщо дублікатів не було
    new Set(ids).has(42);                     // швидка перевірка наявності

    Map — колекція пар «ключ → значення», де ключем може бути будь-що (зокрема обʼєкт), а не лише рядок. На відміну від звичайного обʼєкта, Map гарантує порядок вставлення, має чесний .size і не має успадкованих ключів на кшталт toString.

    const byId = new Map(users.map((u) => [u.id, u]));
    byId.get(2);      // обʼєкт користувача з id === 2
    byId.has(99);     // false
    byId.size;        // кількість записів

    Коли обʼєкт, а коли Map? Обʼєкт — для фіксованої, відомої наперед структури (конфіг, DTO). Map — для динамічного словника, який часто наповнюється, читається за ключем і де важливі порядок, розмір чи нерядкові ключі.

    Копіювання: поверхнева vs глибока

    Найдорожчий за наслідками розділ. Усі звичні способи копіювання — spread, Object.assign, Array.from — роблять поверхневу копію (shallow copy): новий контейнер верхнього рівня, але вкладені обʼєкти лишаються спільними за посиланням.

    const original = { name: 'test', config: { retries: 2 } };
    const shallow = { ...original };
    shallow.name = 'other';          // ок, original.name не змінився
    shallow.config.retries = 5;      // а це змінило й original.config.retries!
    original.config.retries;          // 5 — вкладений обʼєкт спільний

    Глибока копія

    оригінал

    config #1

    копія

    config #2

    Поверхнева копія

    оригінал

    вкладений config

    копія

    Глибока копія

    оригінал

    config #1

    копія

    config #2

    Поверхнева копія

    оригінал

    вкладений config

    копія

    Глибока копія (deep copy) рекурсивно клонує всі рівні — жодного спільного посилання. Сучасний і правильний спосіб — вбудований structuredClone:

    const deep = structuredClone(original);
    deep.config.retries = 5;         // оригінал недоторканий

    structuredClone доступний у браузерах і в Node 17+, коректно клонує вкладені обʼєкти, масиви, Date, Map, Set, навіть циклічні посилання. Обмеження: не клонує функції й прототипи класів (кине помилку на функції).

    Старий трюк — JSON.parse(JSON.stringify(obj)) — теж дає глибоку копію, але тихо втрачає й спотворює дані, тому як універсальний метод небезпечний. Про це — далі.

    JSON.parse і JSON.stringify

    Це міст між обʼєктами в памʼяті й текстом: JSON.stringify серіалізує обʼєкт у рядок (тіло запиту, збереження у файл, лог), JSON.parse розбирає рядок назад в обʼєкт (відповідь сервера). У тестах ти постійно робиш і те, і те, часто неявно — HTTP-клієнт серіалізує тіло за тебе.

    Небезпека в тому, що втрачається при серіалізації, бо JSON знає лише обʼєкти, масиви, рядки, числа, boolean і null:

    JSON.stringify({ a: undefined, b: () => 1, c: NaN, d: new Date(), e: new Set([1]) });
    // {"c":null,"d":"2026-07-18T...","e":{}}

    Що відбувається по пунктах:

    • undefined і функції зникають з обʼєкта (у масиві undefined стає null).
    • NaN та Infinity перетворюються на null.
    • Date стає рядком ISO 8601 — назад parse поверне рядок, а не Date.
    • Map, Set перетворюються на порожній {} — вміст втрачено.
    • Циклічне посилання кидає TypeError.
    • BigInt кидає помилку серіалізації.

    Тому JSON.parse(JSON.stringify(x)) як «глибока копія» руйнує будь-що складніше за примітивні поля. Для копіювання бери structuredClone; JSON лишай для того, для чого він є, — обміну текстом.

    Два корисні аргументи stringify: третій — відступ для читабельного логу (JSON.stringify(obj, null, 2)), другий — replacer-функція або список полів для фільтрації (зручно приховати токен у логах). У JSON.parse симетрично є reviver для пост-обробки — ним, наприклад, перетворюють рядки-дати назад у Date.

    Типові помилки

    Виглядає як «порівняв обʼєкти», а насправді порівняв посилання. expect(actual).toBe({ id: 1 }) завжди падає — toBe/=== для обʼєктів звіряють адресу, а не вміст. Для вмісту — toEqual.

    Виглядає як «скопіював масив», а насправді дав друге імʼя тому самому. const copy = original не копіює — обидві змінні тримають одне посилання, і copy.push() міняє original. Копія — це [...original] або structuredClone.

    Виглядає як «дефолт спрацює лише коли значення немає», а насправді || зʼїдає 0 і ''. port || 3000 підмінить валідний 0. Для «немає значення» бери ??.

    Виглядає як «sort повернув відсортовану копію», а насправді змінив оригінал і зіпсував порядок чисел. sort мутує масив, а без компаратора сортує як рядки: [1, 10, 2]. Завжди передавай (a, b) => a - b і копіюй джерело, якщо його не можна чіпати.

    Виглядає як «?. захистить від усього», а насправді лише від null/undefined. На порожньому рядку чи 0 ланцюжок піде далі — це не «відсутність».

    Виглядає як «глибоко скопіював через JSON», а насправді втратив Date, undefined, Map і функції. JSON.parse(JSON.stringify(x)) — не універсальна глибока копія. Бери structuredClone.

    Підсумок

    • Примітиви живуть за значенням, обʼєкти — за посиланням; === для обʼєктів звіряє посилання, тому {} !== {}, а асерти вмісту — це toEqual, не toBe.
    • ?. коротко замикається лише на null/undefined; ?? підставляє дефолт лише для null/undefined, тоді як || спрацьовує на будь-яке falsy.
    • map/filter/reduce/find/some/every не мутують джерело й ланцюжкуються; sort, reverse, splice — мутують масив на місці.
    • Spread, Object.assign, JSON-трюк — поверхнева копія зі спільними вкладеними обʼєктами; справжня глибока копія — structuredClone.
    • JSON.stringify губить undefined, функції, Map/Set і перетворює Date на рядок — це формат обміну, а не інструмент копіювання.

    Що питають на співбесіді

    • «Чому {} === {} дає false — очікують згадку про посилальну семантику: кожен літерал — новий обʼєкт за новою адресою, === звіряє адресу, а не вміст. Сильний кандидат одразу додасть, що для порівняння за вмістом у тестах є toEqual.
    • «Чим ?? відрізняється від || — інтервʼюер перевіряє розуміння falsy: чи назве кандидат пастку з 0, '', false. Це прямий індикатор, чи писав людина реальні дефолти конфігів.
    • «Як зробити копію обʼєкта? А глибоку?» — дивляться, чи розрізняє поверхневу й глибоку копію й чи знає про спільні вкладені посилання. Плюс — назвати structuredClone і пояснити, чому JSON.parse(JSON.stringify()) ненадійний.
    • «Що робить sort без аргументів з масивом чисел?» — класична пастка на лексикографічне сортування й на мутацію оригіналу.
    • Live coding: «прибери дублікати / знайди спільні елементи / згрупуй масив обʼєктів». Тут дивляться на вибір інструмента (Set для унікальності, reduce/Map для групування) і на те, чи не мутує кандидат вхідні дані. Детальніше формати таких задач — у главі Live coding для AQA: типові задачі та підходи.

    Джерела