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