Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді з JavaScript

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

114 питань

Три крапки ... означають протилежні речі залежно від місця:

  • spread (розгортання) - «розкласти» масив чи об'єкт на окремі елементи. Стоїть там, де використовують значення;
  • rest (решта) - «зібрати» кілька елементів в один масив чи об'єкт. Стоїть там, де оголошують змінні чи параметри.

Spread:

// масиви
const all = [...frontend, ...backend];          // об'єднання
const copy = [...items];                        // поверхнева копія
Math.max(...prices);                            // масив як аргументи функції
const unique = [...new Set(tags)];              // будь-який ітерований об'єкт у масив

// об'єкти
const defaults = { perPage: 20, sort: 'name' };
const options = { ...defaults, ...userOptions }; // пізніші властивості перекривають ранніші
const updated = { ...user, name: 'Іра' };        // копія зі зміненим полем

Rest:

function log(level, ...messages) {}             // решта аргументів у масив
const [first, ...others] = list;                // решта елементів
const { password, ...safeUser } = user;         // усе, крім password

Останній приклад - зручний спосіб прибрати поле з об'єкта без мутації.

Що варто знати про spread:

  • копія поверхнева. Вкладені об'єкти й масиви лишаються спільними:
const copy = { ...settings };
copy.limits.upload = 1;   // змінило й settings.limits

Для глибокої копії - structuredClone(settings).

  • порядок важливий: { ...user, role: 'admin' } перезапише роль, а { role: 'admin', ...user } - ні, якщо в user вона є;
  • spread об'єкта копіює лише власні перелічувані властивості - без прототипу, гетери обчислюються в значення. Тому { ...dateInstance } чи { ...classInstance } дасть простий об'єкт без методів;
  • spread null чи undefined в об'єкті нічого не додає (зручно для умовних полів: { ...(isAdmin && { role: 'admin' }) }), а в масиві - кидає TypeError;
  • дуже великі масиви як аргументи (Math.max(...hugeArray) на сотні тисяч елементів) можуть перевищити ліміт аргументів - для них reduce.

Spread - основа незмінних оновлень стану у React, Pinia, Redux: замість зміни об'єкта створюється новий з потрібними змінами.

У PHP ті самі три крапки: ...$args у параметрах (rest) і [...$a, ...$b] чи f(...$args) (spread).

Докладніше в документації: Spread-синтаксис

Шаблонні рядки записують у зворотних лапках `. Вони підтримують підстановку виразів і багаторядковість:

const name = 'Оля';
const count = 3;

const message = `Привіт, ${name}! У вас ${count} нових ${count === 1 ? 'повідомлення' : 'повідомлень'}.`;

const html = `
  <div class="card">
    <h3>${title}</h3>
  </div>
`;

Усередині ${...} - будь-який вираз: виклик функції, тернарний оператор, арифметика. Результат перетворюється на рядок.

Чим кращі за склеювання через +:

  • читається як готовий текст, без нагромадження лапок і плюсів;
  • переноси рядків зберігаються як є;
  • не треба екранувати одинарні й подвійні лапки всередині.

Пастки:

  • HTML з даними користувача через шаблонний рядок і innerHTML - XSS. Шаблонний рядок нічого не екранує;
  • відступи багаторядкового шаблону потрапляють у рядок;
  • undefined і null підставляться як текст 'undefined'/'null' - видно в інтерфейсі, якщо дані не прийшли.

Теговані шаблони - функція перед шаблоном отримує окремо статичні частини й підставлені значення:

function highlight(strings, ...values) {
  return strings.reduce(
    (result, part, i) => result + part + (i < values.length ? `<mark>${escapeHtml(values[i])}</mark>` : ''),
    '',
  );
}

highlight`Знайдено ${count} результатів для «${query}»`;

Функція бачить, де закінчується довірений текст розробника і починаються дані, - і може їх обробити: екранувати, параметризувати.

Де теговані шаблони використовують:

  • безпечний SQL: бібліотеки на кшталт postgres (sql`SELECT * FROM users WHERE id = ${id}`) перетворюють значення на параметри запиту, а не вставляють у текст - захист від SQL-ін'єкцій;
  • HTML-шаблони з екрануванням: lit-html (html`<p>${text}</p>`);
  • CSS-in-JS: styled-components;
  • GraphQL-запити: gql`...`;
  • String.raw - вбудований тег, що не обробляє екранування: String.raw`C:\new\path` лишає \n як два символи. Зручно для регулярних виразів і шляхів Windows.

Масив статичних частин має властивість raw - ті самі частини без обробки екранування.

Докладніше в документації: Шаблонні літерали

Фронтенд-код ламається так само, як серверний: форма перестає відправлятися, фільтр показує не ті дані, кнопка «Оплатити» неактивна. Без тестів про це дізнаються користувачі, а кожен рефакторинг стає ризиком.

Рівні тестів:

Рівень Що перевіряє Інструменти Швидкість
unit одна функція чи модуль окремо: форматування ціни, валідація, розрахунок кошика Vitest, Jest мілісекунди
компонентні / інтеграційні кілька частин разом у DOM: компонент з формою, відправка, повідомлення про помилку Vitest + Testing Library, jsdom чи справжній браузер десятки мілісекунд
end-to-end (E2E) увесь застосунок у справжньому браузері з бекендом: вхід, оформлення замовлення Playwright, Cypress, Pest browser tests секунди

Піраміда радить багато дешевих unit-тестів, менше інтеграційних і кілька E2E: що вище рівень, то тест повільніший, крихкіший і дорожчий у підтримці.

«Тестовий трофей» (Kent C. Dodds) - альтернативний погляд для фронтенду: найбільше користі дають інтеграційні тести, бо unit-тести окремих функцій інтерфейсу часто перевіряють деталі реалізації, а не те, що бачить користувач. Основа трофея - статичний аналіз: TypeScript і ESLint ловлять цілий клас помилок без жодного тесту.

Що тестувати насамперед:

  • чисту логіку без DOM: розрахунки, перетворення даних, парсинг - найдешевші й найстабільніші тести;
  • критичні сценарії користувача (вхід, оплата, відправка форми) - кількома E2E-тестами;
  • місця, де вже були баги: тест на баг гарантує, що він не повернеться.

Що не тестувати:

  • бібліотеки й фреймворк - вони протестовані авторами;
  • точну розмітку й стилі - такі тести падають від кожної зміни дизайну;
  • приватні деталі реалізації, які користувач не бачить.

Для Laravel-проєкту: бекенд покривають Pest-тестами, а для фронтенду часто достатньо unit-тестів складної логіки в Vitest і браузерних тестів основних сторінок - вони перевіряють і JavaScript, і серверну частину одночасно.

Докладніше в документації: Martin Fowler: The Practical Test Pyramid

Vitest - тестовий фреймворк, побудований на Vite. Він використовує ту саму конфігурацію, плагіни й псевдоніми шляхів, що й збирання застосунку, тож тести бачать код так само, як браузер.

npm install -D vitest
// resources/js/utils/price.js
export function formatPrice(cents) {
  return `${(cents / 100).toFixed(2)} грн`;
}
// resources/js/utils/price.test.js
import { describe, it, expect } from 'vitest';
import { formatPrice } from './price';

describe('formatPrice', () => {
  it('перетворює копійки на гривні', () => {
    expect(formatPrice(12345)).toBe('123.45 грн');
  });

  it('показує нуль', () => {
    expect(formatPrice(0)).toBe('0.00 грн');
  });
});
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Запуск:

  • npx vitest - режим спостереження: при зміні файлу перезапускаються лише пов'язані з ним тести;
  • npx vitest run - один прогін, для CI;
  • npx vitest price - лише файли, у назві яких є price.

Основні перевірки:

Матчер Для чого
toBe примітиви, порівняння за ===
toEqual об'єкти й масиви за вмістом
toStrictEqual те саме, але враховує undefined-поля і класи
toContain, toHaveLength масиви й рядки
toThrow функція кидає помилку
toMatchObject об'єкт містить указані поля

Типова помилка - expect({ a: 1 }).toBe({ a: 1 }): це різні об'єкти, тест падає. Для об'єктів - toEqual.

Чому Vitest, а не Jest, у проєкті на Vite:

  • одна конфігурація: vite.config.js з псевдонімом @/ уже працює в тестах, не треба дублювати її для Jest;
  • ES-модулі й TypeScript без Babel і додаткових трансформерів;
  • швидкий режим спостереження завдяки графу модулів Vite;
  • API сумісний з Jest (describe, it, expect, моки) - перехід зазвичай механічний.

Середовище: за замовчуванням тести виконуються в Node.js. Для коду, що працює з DOM, потрібне браузерне середовище (jsdom, happy-dom чи справжній браузер).

Докладніше в документації: Vitest: початок роботи

Головна небезпека в асинхронних тестах - тест завершується раніше, ніж перевірка. Тест, у якому жодна перевірка не виконалася, вважається пройденим.

Неправильно:

it('завантажує користувача', () => {
  fetchUser(1).then((user) => {
    expect(user.name).toBe('Олена');   // виконається після завершення тесту
  });
});

Тест «зелений» навіть тоді, коли fetchUser повертає зовсім іншого користувача.

Правильно - async/await:

it('завантажує користувача', async () => {
  const user = await fetchUser(1);

  expect(user.name).toBe('Олена');
});

Тестовий фреймворк чекає на Promise, який повертає функція тесту.

Перевірка відхиленого Promise:

it('кидає помилку для неіснуючого користувача', async () => {
  await expect(fetchUser(999)).rejects.toThrow('Not found');
});

it('повертає дані', async () => {
  await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
});

await перед expect(...).rejects обов'язковий - без нього перевірка знову не встигне виконатися.

Варіант з try/catch потребує захисту:

it('кидає помилку', async () => {
  expect.assertions(1);   // тест упаде, якщо перевірок буде не рівно одна

  try {
    await fetchUser(999);
  } catch (error) {
    expect(error.message).toBe('Not found');
  }
});

Без expect.assertions(1) тест пройде, якщо fetchUser помилково не кине винятку - блок catch просто не виконається.

Таймери й затримки не варто чекати по-справжньому (await new Promise(r => setTimeout(r, 3000))): тест стане повільним і нестабільним. Для цього є фейкові таймери.

Перевірка, що все дочекалося:

  • expect.hasAssertions() - хоча б одна перевірка виконалася;
  • vi.waitFor(() => expect(...)) - повторює перевірку, доки вона не пройде чи не мине час, коли результат з'являється не одразу.

Порада: напишіть тест, переконайтеся, що він падає, якщо зламати код. Тест, який ніколи не падав, міг нічого не перевіряти.

Докладніше в документації: Vitest: API expect

Покриття (coverage) - частка коду, яка виконалася під час тестів.

npm install -D @vitest/coverage-v8
npx vitest run --coverage
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
price.js      |     100 |       75 |     100 |     100 | 12
cart.js       |   62.50 |       50 |   66.66 |   62.50 | 18-24,31

Метрики:

Метрика Що рахує
Statements / Lines виконані інструкції чи рядки
Functions функції, які хоч раз викликали
Branches гілки умов: обидві частини if/else, ? :, ??, &&

Branches - найкорисніша: 100% рядків може бути навіть тоді, коли гілка else жодного разу не перевірялася.

Провайдери у Vitest: v8 (за замовчуванням, використовує вбудоване покриття рушія, швидкий) і istanbul (інструментує код, працює в будь-якому середовищі).

Що покриття НЕ показує:

  • чи є перевірки: тест, що викликає функцію без жодного expect, дає 100% покриття і нічого не перевіряє;
  • чи перевірки правильні: код виконано, але очікуваний результат у тесті міг бути скопійований з помилкової реалізації;
  • граничні випадки: рядок price * qty покрито, але ніхто не перевірив від'ємну кількість, нуль, дробові значення;
  • поєднання умов: обидві гілки двох if покрито окремо, але не всі чотири комбінації;
  • інтеграцію: кожен модуль покрито, а разом вони не працюють.

Як користуватися з розумом:

  • дивитися на непокриті рядки, а не на відсоток: звіт показує, яку логіку забули перевірити;
  • поріг у CI (coverage.thresholds) - захист від падіння покриття, але не ціль. Вимога «90% будь-якою ціною» породжує тести без сенсу;
  • виключити згенерований код, конфігурації, типи (coverage.exclude), щоб вони не спотворювали картину.

Що краще оцінює якість тестів - мутаційне тестування (Stryker для JavaScript, Pest Mutate для PHP): інструмент навмисно змінює код (> на >=, + на -) і перевіряє, чи впаде хоч один тест. Якщо мутація «вижила» - тести цей код насправді не перевіряють.

Докладніше в документації: Vitest: покриття

Клас - синтаксис для створення об'єктів з однаковою структурою й поведінкою. Під капотом це звичайна функція-конструктор з прототипом, але запис читабельніший.

class Product {
  static count = 0;          // статичне поле: належить класу, а не екземпляру
  currency = 'UAH';          // поле екземпляра зі значенням за замовчуванням
  #discount = 0;             // приватне поле

  constructor(name, price) {
    this.name = name;
    this.price = price;
    Product.count++;
  }

  get finalPrice() {         // гетер: читається як властивість
    return this.price - this.#discount;
  }

  applyDiscount(amount) {    // метод: спільний для всіх екземплярів через прототип
    this.#discount = amount;
    return this;
  }

  static fromJson(data) {    // статичний метод: фабрика
    return new Product(data.name, data.price);
  }
}

const p = new Product('Книга', 500).applyDiscount(50);
p.finalPrice;       // 450
Product.count;      // 1

Успадкування:

class DigitalProduct extends Product {
  constructor(name, price, url) {
    super(name, price);      // обов'язково до звернення до this
    this.url = url;
  }

  toString() {
    return `${this.name} (${this.url})`;
  }
}

Правила, на яких часто помиляються:

  • super() перед this у конструкторі класу-нащадка - інакше ReferenceError: Must call super constructor ... before accessing 'this';
  • клас без new викликати не можна: Product('x') дає TypeError: Class constructor Product cannot be invoked without 'new';
  • класи не піднімаються як функції: використати клас до його оголошення - ReferenceError (тимчасова мертва зона, як у let);
  • тіло класу завжди в суворому режимі;
  • методи не прив'язані до екземпляра: const fn = p.applyDiscount; fn(10) втратить this. Для обробників подій використовують стрілкову функцію в полі класу чи bind.

Поля чи присвоєння в конструкторі: поля класу оголошують структуру об'єкта в одному місці й створюються до виконання тіла конструктора (для нащадка - одразу після super()).

Статичний блок static { ... } виконується один раз при оголошенні класу - для складної ініціалізації статичних полів.

Коли клас доречний: об'єкти з власним станом і поведінкою, ієрархії, з якими працює бібліотека (веб-компоненти, класи помилок). Для простих даних достатньо об'єктного літерала, а для поведінки без стану - звичайних функцій.

Докладніше в документації: MDN: класи

JavaScript виконує код в одному потоці. Асинхронні дії (таймери, мережа, події) виконує середовище - браузер чи Node.js, а колбеки, що мають спрацювати після них, стають у черги. Event loop бере з черг наступне завдання, коли стек викликів порожній.

Дві черги з різним пріоритетом:

  • Макрозадачі (tasks): setTimeout, setInterval, події користувача, повідомлення з мережі.
  • Мікрозадачі (microtasks): колбеки Promise (then, продовження після await), queueMicrotask, MutationObserver.

Порядок: виконати одну макрозадачу → виконати всі мікрозадачі, що накопичилися (включно з тими, що додалися під час виконання) → (у браузері) відмалювати сторінку → наступна макрозадача.

console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 1, 4, 3, 2

setTimeout(fn, 0) не означає «одразу»: це макрозадача, і вона чекає, поки виконаються всі мікрозадачі.

Практичні наслідки:

  • Довгий синхронний код (великий цикл, важкий JSON.parse) блокує все: кліки, анімацію, рендер. Важку роботу ділять на частини або виносять у Web Worker.
  • Нескінченний ланцюжок мікрозадач теж заморожує сторінку: рендер чекає, поки черга мікрозадач спорожніє.

Докладніше в документації: Модель виконання JavaScript

Усі чотири приймають масив Promise і запускаються паралельно, але по-різному вирішують, коли й чим завершитися:

  • Promise.all - чекає всі. Результат - масив значень у тому ж порядку. Перша ж помилка відхиляє весь результат (інші Promise продовжують виконуватися, але їхні результати губляться).
  • Promise.allSettled - чекає всі, ніколи не відхиляється. Результат - масив об'єктів {status: 'fulfilled', value} або {status: 'rejected', reason}.
  • Promise.race - результат першого, що завершився, хай успіхом, хай помилкою.
  • Promise.any - перший успішний. Відхиляється з AggregateError, лише якщо відхилено всі.
// Усе потрібне для сторінки - інакше показати помилку
const [user, settings] = await Promise.all([getUser(), getSettings()]);

// Розіслати сповіщення й порахувати, скільки не дійшло
const results = await Promise.allSettled(recipients.map(send));
const failed = results.filter((r) => r.status === 'rejected').length;

// Тайм-аут
const data = await Promise.race([fetchData(), sleep(5000).then(() => { throw new Error('timeout'); })]);

// Перше доступне дзеркало
const file = await Promise.any(mirrors.map((url) => fetch(url)));

Нюанс race для тайм-ауту: «програлий» запит не скасовується, він продовжує виконуватися. Для справжнього скасування потрібен AbortController (у fetch - AbortSignal.timeout(5000)).

Докладніше в документації: Promise.all

Старі API (події, таймери, бібліотеки з колбеками) зручно «загорнути» в Promise, щоб використовувати з await. Це називають промісифікацією.

Класичний спосіб - конструктор new Promise:

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = () => reject(new Error(`Не вдалося завантажити ${src}`));
    image.src = src;
  });
}

await sleep(500);
const image = await loadImage('/logo.png');

Правила:

  • resolve і reject діють лише перший раз - повторні виклики ігноруються;
  • виняток, кинутий синхронно всередині функції-виконавця, автоматично відхиляє Promise;
  • не загортайте в new Promise те, що вже повертає Promise, - це зайвий шар і місце для втрачених помилок.

Promise.withResolvers() (ES2024) повертає Promise разом з його resolve і reject назовні:

const { promise, resolve, reject } = Promise.withResolvers();

Це зручно, коли виконання відбувається не в одному колбеку, а десь пізніше - наприклад, чекаємо на відповідь у WebSocket за ідентифікатором запиту:

const pending = new Map();

function request(socket, payload) {
  const id = crypto.randomUUID();
  const { promise, resolve, reject } = Promise.withResolvers();
  pending.set(id, { resolve, reject });
  socket.send(JSON.stringify({ id, ...payload }));
  return promise;
}

socket.onmessage = (event) => {
  const { id, result, error } = JSON.parse(event.data);
  const handlers = pending.get(id);
  pending.delete(id);
  error ? handlers?.reject(new Error(error)) : handlers?.resolve(result);
};

Раніше для цього писали той самий шаблон вручну, зберігаючи resolve у зовнішні змінні з конструктора.

У Node.js для функцій зі стилем (error, result) є готовий util.promisify, а більшість модулів мають Promise-версії (node:fs/promises).

Докладніше в документації: Promise.withResolvers()

for await...of перебирає послідовність значень, що надходять асинхронно: кожен крок циклу чекає на наступне значення.

Асинхронний генератор - найпростіший спосіб таку послідовність створити. Наприклад, обхід API з пагінацією:

async function* fetchAllPages(url) {
  let next = url;
  while (next) {
    const response = await fetch(next);
    const page = await response.json();
    yield* page.data;          // віддати елементи сторінки по одному
    next = page.links.next;    // колекція API Resource у Laravel кладе сюди URL наступної сторінки
  }
}

for await (const user of fetchAllPages('/api/users')) {
  console.log(user.name);
  if (user.id === 42) break;   // решта сторінок навіть не буде завантажена
}

Переваги перед «завантажити все в масив»:

  • лінивість - наступна сторінка запитується лише тоді, коли попередню оброблено;
  • пам'ять - не треба тримати весь набір одночасно;
  • break чи return з циклу зупиняє генератор, і зайві запити не робляться.

Де ще зустрічається:

  • потоки даних: ReadableStream у Node.js, Chrome і Firefox можна перебирати через for await (Safari поки ні - там читають через getReader()):
const response = await fetch('/export.csv');
const decoder = new TextDecoder();
for await (const chunk of response.body) {
  process(decoder.decode(chunk, { stream: true }));
}
  • читання файлів построково в Node.js (readline), події, черги повідомлень.

for await над масивом Promise теж працює, але виконує їх у порядку масиву, а не в порядку завершення, і помилки обробляє незручно: for await (const r of [p1, p2, p3]). Якщо Promise вже запущені, краще Promise.all чи Promise.allSettled.

Array.fromAsync() (ES2024) збирає асинхронну послідовність у масив: const users = await Array.fromAsync(fetchAllPages('/api/users'));.

Докладніше в документації: for await...of

Обидва зберігають пари «ключ - значення», але Map створений саме як словник, а об'єкт - як запис з полями.

Відмінності:

  • Ключі. У об'єкта ключі - лише рядки й символи (число 1 стане рядком '1'). У Map ключем може бути будь-що: об'єкт, функція, NaN.
  • Розмір. map.size - одразу; для об'єкта - Object.keys(obj).length.
  • Порядок. Map зберігає порядок вставки. У об'єкта порядок теж визначений, але з винятком: ключі, схожі на цілі числа, йдуть першими за зростанням.
  • Немає успадкованих ключів. Звичайний об'єкт має прототип, і ключі на кшталт constructor чи __proto__ можуть дати сюрпризи. Map порожній по-справжньому.
  • Швидкодія на частих додаваннях і видаленнях у Map зазвичай краща.
  • JSON. JSON.stringify(map) дає {} - його треба перетворювати: Object.fromEntries(map).
const visits = new Map();
const button = document.querySelector('#buy');

visits.set(button, 1);                       // ключ - DOM-елемент
visits.set(button, visits.get(button) + 1);
visits.has(button);                          // true
for (const [element, count] of visits) { /* ... */ }

Коли що: об'єкт - для структур з відомими полями ({ id, name, email }), JSON і конфігурацій. Map - для словників з динамічними ключами, частих змін і ключів-не-рядків. Для набору унікальних значень - Set.

Докладніше в документації: Map

Змінюють масив на місці (мутують): push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin.

Повертають новий, не чіпаючи початковий: map, filter, slice, concat, flat, flatMap, а також нові (ES2023) toSorted, toReversed, toSpliced і with.

const scores = [30, 10, 20];

const sorted = scores.sort((a, b) => a - b);
// scores теж став [10, 20, 30]: sort змінює масив і повертає його ж

const safe = [30, 10, 20];
const sortedCopy = safe.toSorted((a, b) => a - b); // safe лишився [30, 10, 20]
const replaced = safe.with(0, 99);                 // [99, 10, 20], safe не змінився

Чому це важливо:

  • У React, Vue, Redux мутація стану на місці ламає виявлення змін: посилання на масив те саме, і компонент може не оновитися. Тому toSorted() чи [...items].sort() замість items.sort().
  • Масив з аргументу функції належить коду, який його передав. Відсортувати його на місці - змінити чужі дані непомітно.

Ще пастка sort: без функції порівняння елементи порівнюються як рядки, тож [10, 9, 1].sort() дає [1, 10, 9]. Для чисел потрібен (a, b) => a - b, для українського тексту - (a, b) => a.localeCompare(b, 'uk').

Докладніше в документації: Array.prototype.toSorted

Групування масиву за ознакою довго писали вручну через reduce. З ES2024 для цього є вбудовані функції.

Object.groupBy(items, callback) - результат у звичайному об'єкті, ключі - те, що повернув колбек:

const orders = [
  { id: 1, status: 'paid', total: 100 },
  { id: 2, status: 'new', total: 50 },
  { id: 3, status: 'paid', total: 70 },
];

const byStatus = Object.groupBy(orders, (order) => order.status);
// { paid: [{ id: 1, ... }, { id: 3, ... }], new: [{ id: 2, ... }] }

const bySize = Object.groupBy(orders, ({ total }) => (total >= 70 ? 'large' : 'small'));

Map.groupBy(items, callback) - результат у Map. Потрібен, коли ключ - не рядок: об'єкт, дата, число, яке не має перетворюватися на рядок:

const byCustomer = Map.groupBy(orders, (order) => customersById.get(order.customerId));
byCustomer.get(someCustomer);   // замовлення конкретного об'єкта-клієнта

Особливості:

  • це статичні функції, а не методи масиву: Object.groupBy(arr, fn), а не arr.groupBy(fn). Метод масиву планували, але він конфліктував зі старими бібліотеками, що розширювали Array.prototype;
  • об'єкт від Object.groupBy має прототип null: у нього немає hasOwnProperty, toString тощо. result.hasOwnProperty('paid') кине помилку - перевіряйте через Object.hasOwn(result, 'paid') чи 'paid' in result;
  • групи відсутніх значень просто не створюються - порожніх масивів для «ненайдених» статусів не буде;
  • елементи в групах ідуть у порядку вихідного масиву.

Як це робили раніше (і досі доводиться в старих оточеннях):

const byStatus = orders.reduce((groups, order) => {
  (groups[order.status] ??= []).push(order);
  return groups;
}, {});

Порівняння з Laravel: це аналог collect($orders)->groupBy('status'). Агрегати по групах далі рахують звичайними методами: Object.entries(byStatus).map(([status, list]) => [status, list.length]).

Підтримка: усі сучасні браузери з 2024 року і Node.js 21+. Для старших середовищ - поліфіл (core-js) чи reduce.

Докладніше в документації: Object.groupBy()

Set - колекція унікальних значень. Повторне додавання того самого значення нічого не змінює.

const tags = new Set(['php', 'laravel', 'php']);
tags.size;            // 2
tags.add('vue');
tags.has('laravel');  // true, за O(1)
tags.delete('php');
[...tags];            // ['laravel', 'vue'] - порядок додавання зберігається

Найчастіший прийом - прибрати дублікати:

const unique = [...new Set(ids)];

Унікальність визначається як === (з винятком: NaN дорівнює NaN). Тому об'єкти порівнюються за посиланням - два різні об'єкти { id: 1 } обидва потраплять у множину. Для унікальності об'єктів за полем використовують Map з ключем-полем.

Методи множин (ES2025) - нарешті вбудовані операції теорії множин. Кожен повертає новий Set:

const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);

a.union(b);                // {1, 2, 3, 4}
a.intersection(b);         // {2, 3}
a.difference(b);           // {1}      - є в a, немає в b
a.symmetricDifference(b);  // {1, 4}   - є лише в одній з множин

new Set([2]).isSubsetOf(a);   // true
a.isSupersetOf(new Set([1])); // true
a.isDisjointFrom(new Set([9])); // true - спільних елементів немає

Практичні застосування:

  • синхронізація зв'язків: які теги додати й які прибрати при збереженні форми:
const toAttach = selected.difference(current);
const toDetach = current.difference(selected);

Це те, що в Laravel робить sync() для зв'язків «багато-до-багатьох».

  • права: required.isSubsetOf(userPermissions) - чи має користувач усі потрібні права;
  • швидкі перевірки належності у фільтрах замість array.includes у циклі.

Аргументом нових методів може бути не лише Set, а будь-який об'єкт із size, has() і keys() - наприклад, Map (порівнюються ключі). Масив напряму не підходить: a.union([4]) кине помилку, потрібно a.union(new Set([4])).

Підтримка: усі сучасні браузери з 2024 року, Node.js 22+.

Докладніше в документації: Set

Питання з реальних технічних співбесід - 114 питань у 10 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 37 Middle 41 Senior 36

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії