Питання на співбесіді: Помилки й налагодження
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
10 питань
try - блок коду, у якому може статися помилка. catch - що робити, якщо вона сталася. finally - що виконати в будь-якому разі.
function loadSettings() {
try {
const raw = localStorage.getItem('settings');
return JSON.parse(raw) ?? {};
} catch (error) {
console.warn('Пошкоджені налаштування, використовуємо типові', error);
return {};
} finally {
hideLoader(); // виконається і при успіху, і при помилці
}
}
Як це працює:
- коли в
tryкидається виняток, решта блоку пропускається і керування переходить уcatch; - у
catchдоступний об'єкт помилки:error.name,error.message,error.stack; - якщо помилки не було,
catchне виконується; finallyвиконується завжди - навіть якщо вtryчиcatchбувreturnчи нова помилка.
Змінна в catch необов'язкова:
try {
JSON.parse(input);
} catch {
showError('Некоректний JSON');
}
Кинути власну помилку - throw:
if (!email.includes('@')) {
throw new Error('Некоректна електронна адреса');
}
Кидати варто об'єкти Error (або нащадки), а не рядки: throw 'помилка' не має стеку викликів, і налагоджувати таке набагато важче.
Типові помилки новачків:
- ловити все й мовчати:
catch (e) {}- помилка зникла, а програма працює з неправильними даними. Як мінімум - записати в лог; - занадто великий
try: обгорнути всю функцію, а потім не розуміти, який рядок кинув виняток. Краще обгортати лише те, що справді може впасти і що ви знаєте, як обробити; - очікувати, що
tryзловить помилку в колбекуsetTimeoutчи обробнику події - не зловить, бо колбек виконується пізніше, колиtryуже завершено; - асинхронний код без
await:try { fetchData() } catchне зловить відхилений Promise. Потрібноtry { await fetchData() } catch.
finally для прибирання: зняти індикатор завантаження, розблокувати кнопку, закрити з'єднання - те, що має статися незалежно від результату.
Усі вбудовані помилки - нащадки Error. За типом зазвичай одразу зрозуміло, що пішло не так.
TypeError - операція над значенням не того типу. Найчастіша помилка у фронтенді:
const user = null;
user.name; // TypeError: Cannot read properties of null (reading 'name')
undefined(); // TypeError: undefined is not a function
const PI = 3.14; PI = 3; // TypeError: Assignment to constant variable
Зазвичай означає, що дані прийшли не такі, як очікувалося: API повернув null, елемент не знайдено на сторінці, змінну не ініціалізовано.
ReferenceError - звернення до змінної, якої не існує або яка ще не ініціалізована:
console.log(totla); // ReferenceError: totla is not defined (друкарська помилка)
console.log(x); let x = 1; // ReferenceError: Cannot access 'x' before initialization
SyntaxError - код чи дані неможливо розібрати:
JSON.parse('{ name: "Оля" }'); // SyntaxError: ключі в JSON мають бути в лапках
Синтаксична помилка в самому файлі скрипта не дає йому виконатися взагалі - жоден рядок файлу не спрацює.
RangeError - значення поза допустимими межами:
new Array(-1); // RangeError: Invalid array length
(1.5).toFixed(200); // RangeError: toFixed() digits argument must be between 0 and 100
function f() { f(); } f(); // RangeError: Maximum call stack size exceeded
URIError - некоректний URI: decodeURIComponent('%').
AggregateError - кілька помилок разом, наприклад від Promise.any, коли відхилилися всі Promise (error.errors - масив причин).
Як розрізняти в коді:
try {
data = JSON.parse(text);
} catch (error) {
if (error instanceof SyntaxError) {
showError('Сервер повернув некоректні дані');
} else {
throw error; // невідому помилку - далі, а не ковтати
}
}
Читати повідомлення уважно: Cannot read properties of undefined (reading 'map') каже не про map, а про те, що об'єкт перед .map - undefined. Шукати треба, звідки взялося це undefined.
Стек викликів (error.stack) показує, через які функції пройшов виконання до помилки, - з номерами рядків. У DevTools по ньому можна клікнути й перейти до коду.
console.log - найпростіший спосіб, але для складних помилок значно ефективніше зупинити виконання й подивитися на стан програми зсередини.
Оператор debugger у коді зупиняє виконання в цьому місці, якщо відкрито DevTools:
function calculateTotal(items) {
debugger; // виконання зупиниться тут
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
Без відкритих DevTools оператор нічого не робить. Але в репозиторій його не комітять - лінтери на це скаржаться.
Точки зупинки в DevTools (вкладка Sources) - те саме без зміни коду: клік по номеру рядка. Види:
- умовна - зупиняється лише якщо вираз істинний (
item.price < 0). Незамінно в циклі на тисячу ітерацій; - logpoint - нічого не зупиняє, а друкує вираз у консоль. Як
console.log, але без зміни коду й перезбирання; - на подію - зупинитися на будь-якому
click,submit, таймері (панель Event Listener Breakpoints); - на зміну DOM - зупинитися, коли елемент змінюється чи видаляється (контекстне меню елемента у вкладці Elements). Відповідає на питання «хто змінює цей елемент?»;
- на запит - коли URL запиту містить рядок (XHR/fetch Breakpoints);
- на винятки - «Pause on exceptions», включно з перехопленими.
Після зупинки:
- Scope - значення всіх змінних у поточній функції й замиканнях;
- Call Stack - як ми сюди потрапили; клік на попередню функцію показує її стан;
- Watch - вирази, що перераховуються на кожному кроці;
- консоль працює в контексті зупиненої функції - можна викликати функції й змінювати змінні;
- кроки: Step over (наступний рядок), Step into (увійти у функцію), Step out (вийти з неї), Resume.
Корисні дрібниці:
$0у консолі - поточний виділений у Elements елемент;- «Blackbox script» / «Add to ignore list» - не заходити в код бібліотек (Vue, Alpine) при покроковому виконанні;
- вкладка Network - запити, відповіді, статуси; «Copy as fetch» для повторення запиту в консолі;
- у Livewire-проєктах корисно дивитися запити
livewire/updateу Network - там видно, що компонент відправив і що отримав.
Мініфікований код налагоджувати неможливо - для цього потрібні source maps, які у Vite в режимі розробки є завжди.
Власні класи помилок дають змогу розрізняти помилки за типом, а не за текстом повідомлення, і передавати додаткові дані.
class HttpError extends Error {
constructor(response, options) {
super(`HTTP ${response.status} для ${response.url}`, options);
this.name = 'HttpError';
this.status = response.status;
}
}
class ValidationError extends Error {
constructor(errors) {
super('Дані не пройшли перевірку');
this.name = 'ValidationError';
this.errors = errors; // { email: ['Поле обов'язкове'] }
}
}
this.name варто задавати явно: саме воно відображається в консолі й стеку (ValidationError: Дані не пройшли перевірку). Без нього всі помилки виглядатимуть як Error.
Обробка за типом:
async function request(url, options) {
const response = await fetch(url, options);
if (response.status === 422) {
throw new ValidationError((await response.json()).errors);
}
if (!response.ok) {
throw new HttpError(response);
}
return response.json();
}
try {
await request('/api/orders', { method: 'POST', body });
} catch (error) {
if (error instanceof ValidationError) {
showFieldErrors(error.errors); // формат помилок валідації Laravel
} else if (error instanceof HttpError && error.status === 403) {
showError('Недостатньо прав');
} else {
throw error;
}
}
cause (ES2022) - посилання на початкову помилку, що спричинила поточну. Передається другим аргументом конструктора:
try {
config = JSON.parse(await readConfig());
} catch (error) {
throw new Error('Не вдалося завантажити конфігурацію', { cause: error });
}
Навіщо: на вищому рівні потрібне зрозуміле повідомлення («не вдалося завантажити конфігурацію»), але без втрати технічної причини («Unexpected token } in JSON at position 42»). Раніше доводилося або губити початкову помилку, або склеювати повідомлення рядками. Тепер ланцюжок причин зберігається повністю, і DevTools та Sentry показують його.
Це аналог previous у винятках PHP (new RuntimeException('...', previous: $e)).
Що варто знати:
instanceofпрацює з класами, що наслідуютьError, - у сучасних середовищах без хитрощів (раніше Babel ламав це при компіляції в ES5);- не варто створювати клас на кожну дрібницю - лише там, де код по-різному реагує на помилки;
- помилки, що перетинають межі (з воркера, з
postMessage, з серверу), втрачають клас - при серіалізації залишаються лишеnameіmessage. Тип доведеться відновлювати заnameчи кодом.
Якщо Promise відхилено, а обробника помилки (catch, другого аргументу then, try навколо await) немає, помилка стає необробленою.
У браузері сторінка продовжує працювати, але:
- у консолі з'являється
Uncaught (in promise) Error: ...; - на
windowспрацьовує подіяunhandledrejection.
У Node.js з версії 15 необроблене відхилення завершує процес з помилкою. Сервер на Node, де забули catch, падає від першого ж збою мережі.
Глобальні обробники в браузері:
// необроблені винятки в синхронному коді й колбеках
window.addEventListener('error', (event) => {
report({
message: event.message,
source: event.filename,
line: event.lineno,
stack: event.error?.stack,
});
});
// необроблені відхилення Promise
window.addEventListener('unhandledrejection', (event) => {
report({ message: String(event.reason), stack: event.reason?.stack });
// event.preventDefault() - прибрати повідомлення з консолі
});
event.reason - те, з чим відхилено Promise (зазвичай об'єкт Error, але може бути будь-що).
Подія rejectionhandled спрацьовує, якщо обробник додали пізніше, ніж браузер зафіксував відхилення як необроблене. Буває при «лінивому» підключенні catch.
Що ці обробники дають і чого ні:
- це останній рубіж для моніторингу, а не спосіб обробляти помилки. Зловити тут помилку й «продовжити» неможливо: функцію, що впала, уже не повернути;
- саме так працюють Sentry, Flare та інші системи: підписуються на ці події й надсилають звіти на сервер;
- помилки зі скриптів іншого домену (CDN) приходять як
Script error.без деталей - браузер приховує їх з міркувань безпеки. Деталі з'являться, якщо скрипт підключено зcrossorigin="anonymous"і сервер віддає заголовок CORS.
Типові джерела необроблених відхилень:
fetch(...).then(...)безcatchнаприкінці ланцюжка;- виклик async-функції без
await(«запустив і забув»):saveDraft();в обробнику події; Promise.all, у якому відхилився один Promise, - решта відхилень після першого вже ігноруються, але перше має бути оброблене.
Правило: кожен ланцюжок Promise має закінчуватися обробкою помилки, а «запущені й забуті» async-виклики - явним .catch(report). Лінтер (@typescript-eslint/no-floating-promises) знаходить такі місця автоматично.
Об'єкт console має значно більше, ніж log, і правильний метод часто економить час налагодження.
Рівні повідомлень - фільтруються в DevTools:
console.debug('деталі'); // приховано за замовчуванням (рівень Verbose)
console.info('інформація');
console.warn('попередження'); // жовте, зі стеком
console.error('помилка'); // червоне, зі стеком
console.table - масив об'єктів таблицею:
console.table(users, ['id', 'name', 'email']); // лише вибрані колонки
console.group / groupCollapsed / groupEnd - вкладені групи повідомлень:
console.groupCollapsed(`Замовлення ${order.id}`);
console.log('товари', order.items);
console.log('доставка', order.shipping);
console.groupEnd();
console.time / timeLog / timeEnd - вимір часу:
console.time('рендер');
renderList(items);
console.timeEnd('рендер'); // рендер: 48.2 ms
console.count - скільки разів виконався рядок (корисно для пошуку зайвих рендерів):
console.count('render ProductCard');
console.trace - стек викликів у цьому місці: відповідає на питання «хто викликав цю функцію?».
console.assert(condition, ...) - пише помилку, лише якщо умова хибна:
console.assert(total >= 0, 'Від\'ємна сума', { total, items });
console.dir(element) - DOM-елемент як об'єкт з властивостями, а не як HTML.
Підстановки й стилі:
console.log('%cУвага', 'color: red; font-weight: bold', 'звичайний текст');
Пастка - лінивий показ об'єктів. console.log(obj) показує об'єкт за посиланням: коли ви розгорнете його в консолі пізніше, побачите поточний стан, а не стан на момент виклику. Якщо об'єкт змінюється, логуйте знімок: console.log(structuredClone(obj)) чи JSON.stringify(obj).
Утиліти, доступні лише в консолі DevTools: $0 (виділений елемент), $$('selector') (масив елементів), copy(obj) (скопіювати в буфер), monitorEvents(window, 'resize').
На продакшені console.* у коді - шум і інколи витік даних. Збирач може прибрати їх (у Vite - опція esbuild.drop: ['console', 'debugger']), а для справжніх помилок потрібна система моніторингу, а не консоль користувача.
try...catch ловить лише помилки, що виникають під час виконання блоку try - поки цей код на стеку викликів.
try {
setTimeout(() => {
null.x; // помилка тут
}, 0);
} catch (error) {
console.log('спіймано'); // НЕ виконається
}
Чому: setTimeout лише реєструє колбек і одразу повертає керування. Блок try завершується без помилок. Колбек виконується пізніше, з власного стеку, коли цикл подій дійде до таймера. Зовнішнього try на цьому стеку вже немає. Помилка стає необробленою й іде в глобальний обробник window.onerror.
Те саме для:
- обробників подій (
addEventListener); - колбеків старих асинхронних API;
then-колбеків Promise, якщо не підключеноcatch.
Як правильно:
1. try всередині колбека:
setTimeout(() => {
try {
riskyWork();
} catch (error) {
report(error);
}
}, 0);
2. Перетворити на Promise і використовувати await:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
try {
await sleep(100);
riskyWork(); // тепер у тій самій async-функції - try спрацює
} catch (error) {
report(error);
}
await «зшиває» асинхронні кроки в одну логічну послідовність: після очікування виконання продовжується всередині того самого try.
3. Але await має бути:
try {
saveDraft(); // async-функція без await
} catch (error) { // не спіймає відхилення
}
try {
await saveDraft(); // спіймає
} catch (error) {
}
4. return з try в async-функції: return fetchData() повертає Promise без очікування - відхилення пройде повз catch цієї функції. return await fetchData() в try - спіймає.
Обробники подій - помилку краще ловити в самому обробнику або загорнути обробники обгорткою:
const safe = (handler) => async (event) => {
try {
await handler(event);
} catch (error) {
report(error);
showToast('Щось пішло не так');
}
};
button.addEventListener('click', safe(onSave));
Загальне правило: помилку можна спіймати лише там, де її очікують. В асинхронному коді очікування - це await чи catch на відповідному Promise.
Докладніше в документації: Керування потоком і обробка помилок
Помилки фронтенду відбуваються в браузерах користувачів, а не на вашому сервері: якщо їх не збирати, про них дізнаються зі скарг (або не дізнаються взагалі).
Джерела помилок:
windowподіяerror- необроблені винятки, а також помилки завантаження ресурсів (зображення, скрипти) при підписці зcapture: true;unhandledrejection- необроблені відхилення Promise;- явні виклики
report(error)уcatch, де помилку обробили, але про неї треба знати; - обробники помилок фреймворків:
app.config.errorHandlerу Vue, error boundaries у React - вони ловлять помилки рендеру, які інакше лише покажуть порожній екран.
Готові рішення - Sentry, Bugsnag, Flare (від авторів Ignition, з інтеграцією з Laravel): SDK підписується на всі ці події, групує однакові помилки й показує частоту.
Що робить звіт корисним:
- читабельний стек - з source maps, завантаженими в систему моніторингу при деплої (самі карти на сервер не публікують);
- версія релізу - щоб розуміти, в якому деплої з'явилася помилка і чи виправив її новий;
- «хлібні крихти» (breadcrumbs) - що відбувалося перед помилкою: кліки, переходи, мережеві запити, повідомлення консолі. Найчастіше саме вони пояснюють, як відтворити проблему;
- контекст: сторінка, браузер, ОС, користувач (ідентифікатор, а не персональні дані);
- зв'язок з бекендом - ідентифікатор запиту (trace id), щоб знайти відповідний запис у логах Laravel.
Шум, який треба фільтрувати:
- помилки розширень браузера й вбудованих скриптів (стек вказує на
chrome-extension://); Script error.без деталей - скрипти з інших доменів безcrossorigin;ResizeObserver loop completed with undelivered notifications- зазвичай нешкідливе попередження;- помилки мережі при закритті вкладки,
AbortErrorвід свідомо скасованих запитів; - помилки завантаження частин після деплою (
Failed to fetch dynamically imported module): старі вкладки просять файли, яких уже немає. Це треба обробляти (запропонувати оновити сторінку), а не лише рахувати.
Обсяг і ціна:
- вибірка (sampling) - на великому трафіку надсилати частину однакових помилок, а не всі;
- обмеження частоти з одного браузера - одна помилка в циклі рендеру може згенерувати тисячі звітів за хвилину;
- конфіденційність: не відправляти вміст полів форм, токени, повні URL з персональними даними в параметрах.
Метрика, на яку дивитися: не загальна кількість помилок, а кількість користувачів, які зіткнулися з кожною помилкою, і помилки, що з'явилися в останньому релізі.
Блок finally виконується після try і catch у будь-якому разі. Але якщо він сам завершується через return, throw, break чи continue, це замінює результат усього try...catch.
return у finally ковтає виняток:
function save() {
try {
throw new Error('Диск заповнено');
} finally {
return 'ok';
}
}
save(); // 'ok' - помилка зникла без сліду
І перекриває return з try:
function getStatus() {
try {
return 'from try';
} finally {
return 'from finally';
}
}
getStatus(); // 'from finally'
throw у finally замінює початкову помилку:
try {
throw new Error('справжня причина');
} finally {
cleanup(); // якщо cleanup() кине свою помилку, «справжня причина» загубиться
}
Це особливо підступно: у логах буде помилка прибирання («Cannot read properties of null»), а справжня проблема - прихована.
Що відбувається насправді. Коли try завершується (return, виняток), результат «запам'ятовується», виконується finally, і лише потім запам'ятований результат застосовується. Але якщо finally сам завершився «різко», запам'ятоване відкидається.
Ще одна тонкість - значення return обчислюється до finally:
function f() {
let x = 1;
try {
return x; // повернеться 1
} finally {
x = 2; // змінна змінилася, але значення вже обчислено
}
}
З об'єктом інакше: повертається посилання, і зміна властивостей у finally буде видна.
Правила для finally:
- лише прибирання: закрити, зняти блокування, приховати індикатор;
- ніколи не
return,break,continue- лінтерno-unsafe-finallyловить це автоматично; - прибирання, що саме може впасти, загортати у власний
try...catch, щоб не перекрити основну помилку:
} finally {
try {
await connection.close();
} catch (closeError) {
report(closeError); // повідомити, але не перекрити помилку з try
}
}
Сучасна альтернатива для гарантованого прибирання ресурсів - using з явним керуванням ресурсами (Symbol.dispose): ресурс звільняється автоматично при виході з блоку. Він уже є в TypeScript і поступово з'являється в рушіях JavaScript.
Аналогія з PHP: там return у finally теж перекриває і return, і виняток з try - правило однакове для обох мов.
Найчастіша проблема з помилками - не відсутність обробки, а обробка не в тому місці: try...catch у кожній функції, де помилку логують і повертають null, а вищий рівень так і не дізнається, що щось пішло не так.
Принцип: ловити там, де знаєш, що робити.
Функція, яка не може осмислено відреагувати на помилку, має пропустити її далі. «Що робити» майже завжди вирішує рівень, близький до користувача.
Шари типового застосунку:
1. Низький рівень (HTTP-клієнт, парсери) - перетворює помилки на зрозумілі типи, але не ховає їх:
async function apiRequest(url, options) {
const response = await fetch(url, options);
if (response.status === 422) throw new ValidationError((await response.json()).errors);
if (response.status === 401) throw new UnauthenticatedError();
if (!response.ok) throw new HttpError(response);
return response.json();
}
2. Сервіси й бізнес-логіка - зазвичай не ловлять нічого. Винятки - очікувані ситуації з альтернативною поведінкою: кеш недоступний - читаємо напряму; необов'язкова рекомендація не завантажилася - показуємо сторінку без неї.
3. Межа взаємодії (обробник форми, дія користувача) - тут вирішують, що показати:
async function onSubmit() {
try {
await saveOrder(form);
showToast('Замовлення збережено');
} catch (error) {
if (error instanceof ValidationError) return showFieldErrors(error.errors);
if (error instanceof UnauthenticatedError) return redirectToLogin();
report(error); // неочікуване - у моніторинг
showToast('Не вдалося зберегти. Спробуйте ще раз');
}
}
4. Глобальний рубіж - window.onerror, unhandledrejection, errorHandler фреймворку, error boundary: для того, що проскочило, - звіт у моніторинг і запасний інтерфейс замість білого екрана.
Очікувані й неочікувані помилки:
- очікувані (валідація, 404 «товар не знайдено», немає прав) - частина звичайного сценарію. Їх обробляють і не надсилають у моніторинг як помилки;
- неочікувані (
TypeError, 500) - баги. Їх показують користувачеві загальним повідомленням і обов'язково звітують.
Змішування призводить до того, що моніторинг завалений тисячами «помилок валідації», а справжній баг тоне в шумі.
Антипатерни:
catch (e) { console.log(e) }без подальшої дії - помилка «оброблена», але застосунок у зламаному стані;- повернення
null/falseзамість винятку - виклики далі мусять перевіряти результат, і рано чи пізно перевірку забудуть; - однакове «Щось пішло не так» для всього - і для помилки валідації, і для недоступного сервера;
catchлише для того, щобthrowту саму помилку - шум.
Альтернатива винятками - тип-результат ({ ok: true, value } / { ok: false, error }), популярний у TypeScript для очікуваних помилок: компілятор змушує обробити обидва варіанти. Але для неочікуваних помилок винятки лишаються природнішими.