Коротка відповідь. На live coding оцінюють не «знав/не знав задачу», а процес: як ти уточнюєш вимоги, чи пишеш спершу просту робочу версію, чи бачиш крайні випадки, чи можеш пояснити складність. Нижче п'ять задач, що покривають більшість реальних співбесід рівня Junior–Middle. Для кожної – однакова структура: умова → що уточнити → рішення → edge cases → як тестувати. Проходь їх саме в цьому форматі, а не «глянув рішення – зрозумів».

Загальний алгоритм на будь-якій задачі

  1. Переформулюй умову своїми словами і постав 1–2 уточнювальні питання.
  2. Назви сигнатуру функції та типи до того, як писати тіло.
  3. Напиши найпростішу робочу версію, скажи про це вголос.
  4. Пройдись по edge cases і полагодь, що треба.
  5. Заверши фразою «тестував би так: …» – навіть якщо тестів не просили.

Задача 1. debounce

Умова: реалізуй debounce(fn, delay) – функція викликається лише після паузи delay мс від останнього виклику.

Що уточнити: чи потрібен виклик із першого разу (leading), чи зберігати this і аргументи, чи потрібен метод скасування.

function debounce<Args extends unknown[]>(
  fn: (...args: Args) => void,
  delay: number
): ((...args: Args) => void) & { cancel: () => void } {
  let timer: ReturnType<typeof setTimeout> | undefined;

  const debounced = (...args: Args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
  debounced.cancel = () => clearTimeout(timer);

  return debounced;
}

Edge cases: повторний виклик до спливання таймера (попередній має скасуватись – саме для цього clearTimeout); виклик cancel до першого спрацювання; delay = 0 (усе одно асинхронно – див. event loop).

Як тестувати: фейкові таймери (vi.useFakeTimers() у vitest): викликати тричі поспіль, промотати час, перевірити один виклик з останніми аргументами.

Типова помилка: забути clearTimeout – тоді це не debounce, а «відкладений виклик кожного разу».

Задача 2. groupBy

Умова: groupBy(items, keyFn) – розбий масив на групи за ключем.

Що уточнити: що повертати – об'єкт чи Map; чи може ключ бути не рядком.

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

// groupBy(['one', 'two', 'six'], (w) => w.length)
// => { 3: ['one', 'two', 'six'] }

Edge cases: порожній масив (порожній об'єкт, не помилка); ключі-числа стають рядковими ключами об'єкта; якщо ключем може бути об'єкт – потрібен Map, і це гарний привід сказати про різницю (детальніше – у статті про структури даних).

Як тестувати: групування за довжиною слова, за булевим предикатом, порожній вхід, стабільність порядку всередині групи.

Типова помилка: result[key].push(...) без перевірки існування групи – TypeError на першому елементі групи.

Задача 3. flatten

Умова: розплющ вкладений масив довільної глибини: flatten([1, [2, [3, [4]]]]) → [1, 2, 3, 4].

Що уточнити: глибина обмежена чи довільна; чи можна використовувати вбудований Array.prototype.flat.

type Nested<T> = (T | Nested<T>)[];

function flatten<T>(input: Nested<T>): T[] {
  const result: T[] = [];
  for (const item of input) {
    if (Array.isArray(item)) {
      result.push(...flatten(item));
    } else {
      result.push(item);
    }
  }
  return result;
}

Сильний хід: спершу сказати «у продакшні – arr.flat(Infinity)», а потім написати ручну версію, бо питають саме її. Якщо інтерв'юер попросить без рекурсії – ітеративна версія зі стеком: знімай елемент, масиви розгортай назад у стек, інакше додавай у результат.

Edge cases: порожні масиви на будь-якому рівні; дуже глибока вкладеність (рекурсія може впертись у stack overflow – тому й існує ітеративний варіант); значення undefined/null як легальні елементи.

Як тестувати: глибина 0/1/довільна, порожні підмасиви, великий вхід на переповнення стека для рекурсивної версії.

Задача 4. EventEmitter

Умова: клас з on, off, emit.

Що уточнити: чи може бути кілька слухачів однієї події; що робить off без конкретного слухача; чи потрібен once.

type Listener = (...args: unknown[]) => void;

class EventEmitter {
  private listeners = new Map<string, Set<Listener>>();

  on(event: string, listener: Listener): () => void {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event)!.add(listener);
    return () => this.off(event, listener);
  }

  off(event: string, listener: Listener): void {
    this.listeners.get(event)?.delete(listener);
  }

  emit(event: string, ...args: unknown[]): void {
    for (const listener of [...(this.listeners.get(event) ?? [])]) {
      listener(...args);
    }
  }
}

Дві деталі, які відрізняють Middle-відповідь: on повертає функцію відписки (патерн, знайомий кожному, хто писав useEffect-cleanup у React – див. React live coding); в emit ітеруємо копію набору, щоб слухач, який відписується під час події, не ламав ітерацію.

Edge cases: emit події без слухачів (тиша, не помилка); подвійна підписка того самого слухача (Set гарантує один запис); відписка всередині emit.

Як тестувати: підписка → emit → перевірка викликів і аргументів; відписка через повернуту функцію; два слухачі на одну подію.

Задача 5. chunk

Умова: chunk([1,2,3,4,5], 2) → [[1,2],[3,4],[5]].

Що уточнити: що робити з size ≤ 0 – кинути помилку чи повернути порожній масив (домовся і зафіксуй словами).

function chunk<T>(items: T[], size: number): T[][] {
  if (!Number.isInteger(size) || size <= 0) {
    throw new RangeError(`size має бути додатним цілим, отримано ${size}`);
  }
  const result: T[][] = [];
  for (let start = 0; start < items.length; start += size) {
    result.push(items.slice(start, start + size));
  }
  return result;
}

Edge cases: порожній масив → []; size більший за довжину → один шматок; неціле/від'ємне size → явна помилка з повідомленням (валідація вхідних даних – дешевий спосіб показати продакшн-звичку).

Як тестувати: рівний і нерівний поділ, size = 1, size > length, невалідний size (перевірка на кинуту помилку).

Бонус. retry для нестабільного запиту

Частий фінал співбесіди «на подумати»: повторюй асинхронну операцію до attempts разів із паузою між спробами.

Що уточнити: пауза фіксована чи зростає; які помилки ретраїти (усі чи лише мережеві).

async function retry<T>(
  operation: () => Promise<T>,
  attempts: number,
  delayMs: number
): Promise<T> {
  let lastError: unknown;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;
      if (attempt < attempts) {
        await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
      }
    }
  }
  throw lastError;
}

Edge cases: attempts = 1 (жодного повтору – просто виклик); операція, що падає завжди (має вилетіти остання помилка, не перша); пауза після останньої невдачі не потрібна – зверни увагу на умову attempt < attempts.

Як тестувати: мок-операція, що падає N разів і потім успішна; перевірка кількості викликів; фейкові таймери для пауз. Механіка «чому await із setTimeout працює саме так» – у розборі event loop, а патерни навколо промісів – у статті про async/await і Promise.

Як працювати з цією добіркою

Один день – одна задача, під таймер 25–30 хвилин, із проговорюванням уголос усіх п'яти кроків алгоритму. Наступного дня – спершу відтворити вчорашню з нуля, потім нова. Написання тестів до власних рішень – окрема навичка, яку теж перевіряють: як саме їх будувати – у статті тести для live-coding задач. Типи в сигнатурах вище – базовий TypeScript; якщо генерики поки лякають, спершу пройди розбір TypeScript generics. Довідник по всіх вбудованих методах, які тут використані, – MDN JavaScript.