Перейти к содержанию

10 паттернов обработки ошибок в JavaScript, которые действительно важны

От try/catch и собственных классов ошибок до React Error Boundaries и запасных обработчиков на уровне процесса.

Обработка ошибок проста, пока код небольшой.

Вызываете функцию, оборачиваете её в try/catch, пишете ошибку в лог и идёте дальше.

Настоящие приложения устроены иначе. Ошибки появляются внутри промисов, таймеров, обработчиков событий, вызовов API, кода отрисовки или на несколько слоёв ниже функции, которая запустила операцию.

Сложность редко в том, чтобы написать catch. Сложность в том, чтобы решить, где ошибке место, кто должен её обработать и что должно произойти дальше.

Это пятая часть серии JS Coding Techniques.

Предыдущие части:

Дальше — десять паттернов обработки ошибок, которые полезны в повседневном JavaScript.

1. Держите обработку асинхронных ошибок в одном месте

У цепочек промисов своя обработка ошибок:

1
2
3
4
5
6
7
fetchUser(userId)
    .then((user) => {
        renderUser(user);
    })
    .catch((error) => {
        console.error('Не удалось загрузить пользователя:', error);
    });

В этом подходе нет ничего плохого.

Но если функция уже использует async/await, блок try/catch обычно делает поток управления понятнее.

1
2
3
4
5
6
7
8
async function loadUser(userId) {
    try {
        const user = await fetchUser(userId);
        renderUser(user);
    } catch (error) {
        console.error('Не удалось загрузить пользователя:', error);
    }
}

Это особенно полезно, когда несколько операций относятся к одной задаче.

async function loadDashboard(userId) {
    try {
        const user = await fetchUser(userId);
        const projects = await fetchProjects(user.id);

        renderDashboard({ user, projects });
    } catch (error) {
        console.error('Не удалось загрузить дашборд:', error);
    }
}

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

Важная деталь: у отклонённого промиса всё равно должен быть потребитель. Если асинхронная функция отклоняется, а её никто не ожидает с обработкой ошибки и к ней не прикреплён .catch(), отказ становится необработанным.

Поэтому так вызывать рискованно:

loadDashboard(userId);

Если за сбой отвечает вызывающий код, обрабатывайте его там:

1
2
3
loadDashboard(userId).catch((error) => {
    reportError(error);
});

Полезное правило простое: у каждого промиса в итоге должен быть владелец, отвечающий за его сбой.

2. try/catch не перехватывает асинхронные колбэки

На первый взгляд это выглядит разумно:

1
2
3
4
5
6
7
try {
    setTimeout(() => {
        throw new Error('Что-то пошло не так');
    }, 100);
} catch (error) {
    console.error('Поймано:', error);
}

Но блок catch эту ошибку никогда не получит.

Колбэк выполняется позже, когда исходный блок try уже завершился.

Если сам колбэк может выбросить исключение, обрабатывайте ошибку внутри этого пути выполнения:

1
2
3
4
5
6
7
setTimeout(() => {
    try {
        runBackgroundTask();
    } catch (error) {
        console.error('Фоновая задача завершилась с ошибкой:', error);
    }
}, 100);

Та же идея относится ко многим API на колбэках.

1
2
3
4
5
6
7
button.addEventListener('click', () => {
    try {
        updateAccount();
    } catch (error) {
        showErrorMessage(error);
    }
});

Промисы ведут себя иначе, потому что отказ проходит по цепочке промиса:

1
2
3
4
5
6
7
Promise.resolve()
    .then(() => {
        throw new Error('Сбой');
    })
    .catch((error) => {
        console.error('Поймано:', error);
    });

Работая с асинхронным кодом, всегда задавайте один вопрос: куда уйдёт ошибка из этого колбэка или промиса?

Если ясного ответа нет, скорее всего, путь обработки ошибки отсутствует.

3. Добавьте глобальный предохранитель для необработанных отказов промисов

Основную работу должна делать локальная обработка ошибок.

И всё же приложению полезно иметь последнюю страховочную сеть для сбоев, которые ушли от обычных обработчиков.

В браузере:

1
2
3
4
5
window.addEventListener('unhandledrejection', (event) => {
    console.error('Необработанный отказ промиса:', event.reason);

    reportError(event.reason);
});

Можно также наблюдать необработанные синхронные ошибки:

1
2
3
window.addEventListener('error', (event) => {
    reportError(event.error ?? event.message);
});

Для Node.js:

1
2
3
4
5
process.on('unhandledRejection', (reason) => {
    console.error('Необработанный отказ промиса:', reason);

    reportError(reason);
});

Эти обработчики не должны становиться обычной архитектурой приложения.

Их задача — поймать сбои, которые проскочили через всё остальное, и убедиться, что вы о них узнали.

В продакшене часто недостаточно писать только в консоль. Когда приложение работает на чужой машине или сервере, нужен контекст, достаточный, чтобы разобраться, что произошло.

Сюда могут входить стек вызовов, версия приложения, выполняемая операция, идентификатор запроса или другая нечувствительная диагностическая информация.

Считайте глобальные обработчики последней тревогой, а не первой линией обороны.

4. Заведите классы ошибок для сбоев, которые нужно различать

Бросать везде Error работает, пока вызывающему коду не нужно реагировать на разные сбои по-разному.

Рассмотрим ошибки валидации и сети.

class ValidationError extends Error {
    constructor(message, field) {
        super(message);

        this.name = 'ValidationError';
        this.field = field;
    }
}

class HttpError extends Error {
    constructor(message, status) {
        super(message);

        this.name = 'HttpError';
        this.status = status;
    }
}

Теперь функция может сообщить больше, чем текстовое сообщение:

1
2
3
4
5
function validateUser(user) {
    if (!user.name?.trim()) {
        throw new ValidationError('Имя обязательно', 'name');
    }
}

Вызывающий код может отреагировать на фактический тип ошибки:

try {
    validateUser(formData);
    await saveUser(formData);
} catch (error) {
    if (error instanceof ValidationError) {
        showFieldError(error.field, error.message);
        return;
    }

    throw error;
}

Обратите внимание на финальный throw error.

Это важно.

Если текущий слой умеет обработать ошибку валидации, но не знает, что делать с неожиданным сбоем базы данных или сети, он не должен молча её поглотить.

Собственные ошибки полезнее всего, когда несут информацию, на которую вызывающий код может отреагировать.

5. Сохраняйте исходную ошибку через cause

Иногда низкоуровневой ошибке нужен дополнительный контекст, прежде чем она поднимется по стеку.

Представьте, что запрос к API завершился неудачей:

async function fetchProfile(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        return await response.json();
    } catch (error) {
        throw new Error('Не удалось загрузить профиль пользователя', {
            cause: error,
        });
    }
}

Сообщение верхнего уровня объясняет, что приложение пыталось сделать:

Не удалось загрузить профиль пользователя

Но исходная ошибка по-прежнему доступна через error.cause.

Это гораздо лучше, чем заменять исходный сбой новой ошибкой, не связанной с ним.

Без cause такой код теряет полезный контекст:

1
2
3
catch {
    throw new Error('Запрос не удался');
}

Хорошей ошибке часто нужны две части информации:

  • Какая операция не удалась?
  • Почему она не удалась?

Error.cause даёт чистый способ сохранить и то, и другое.

6. Решите, когда сбой исключительный, а когда обычный

Не каждая неуспешная операция заслуживает исключения.

Допустим, поиск может законно ничего не вернуть.

Можно написать так:

1
2
3
4
5
6
7
8
9
function findUser(id) {
    const user = users.find((user) => user.id === id);

    if (!user) {
        throw new Error('Пользователь не найден');
    }

    return user;
}

Но если «не найдено» — ожидаемый результат, возврат null описывает API яснее:

1
2
3
function findUser(id) {
    return users.find((user) => user.id === id) ?? null;
}

Вызывающий код тогда обрабатывает эту ветку как обычную:

1
2
3
4
5
6
7
8
const user = findUser(userId);

if (!user) {
    showEmptyState();
    return;
}

showUser(user);

Сравните это с функцией, где сбой действительно не даёт запрошенной операции продолжиться:

1
2
3
4
5
6
7
8
9
async function chargeOrder(order) {
    const response = await paymentClient.charge(order);

    if (!response.ok) {
        throw new PaymentError('Платёж не удалось завершить', response.status);
    }

    return response.receipt;
}

Нет универсального правила, что бизнес-сбои должны возвращать значения, а ошибки программирования — бросать исключения.

Лучше спросить иначе: этот результат входит в ожидаемый контракт функции или означает, что операция не удалась?

Выбирайте API исходя из этого различия и держите его согласованным.

7. Не ловите ошибку только для того, чтобы она исчезла

Это одна из самых лёгких ошибок, которые можно написать:

1
2
3
4
5
try {
    await saveSettings(settings);
} catch (error) {
    // Игнорируем
}

Приложение продолжает работу так, будто сохранение удалось.

Позже кто-то сообщает, что настройки всё время пропадают.

Теперь проблем две: исходный сбой и отсутствие следов, которые его объясняют.

Если текущий слой может восстановиться, восстанавливайтесь:

1
2
3
4
5
6
try {
    await saveSettings(settings);
} catch (error) {
    showMessage('Настройки не удалось сохранить.');
    reportError(error);
}

Если восстановиться нельзя, добавьте полезный контекст и пробросьте сбой:

1
2
3
4
5
6
7
try {
    await saveSettings(settings);
} catch (error) {
    throw new Error('Не удалось сохранить настройки аккаунта', {
        cause: error,
    });
}

Бывают случаи, когда игнорировать ошибку — намеренное решение.

Например, код очистки может пытаться удалить временный ресурс, которого уже нет.

1
2
3
4
5
6
7
try {
    await removeTemporaryFile(path);
} catch (error) {
    if (!(error instanceof FileNotFoundError)) {
        throw error;
    }
}

Это не слепое проглатывание ошибки. Это явная обработка сбоя, который вы уже понимаете.

У блока catch должна быть причина существовать.

8. Используйте Error Boundaries для сбоев отрисовки в React

У React-приложений есть ещё одна категория сбоев: компонент может выбросить исключение, пока React рисует интерфейс.

Error Boundary может изолировать этот сбой и заменить сломанную часть дерева запасным интерфейсом.

import { Component } from 'react';

class ErrorBoundary extends Component {
    state = {
        hasError: false,
    };

    static getDerivedStateFromError() {
        return {
            hasError: true,
        };
    }

    componentDidCatch(error, info) {
        reportError(error, {
            componentStack: info.componentStack,
        });
    }

    render() {
        if (this.state.hasError) {
            return <p>Этот раздел не удалось загрузить.</p>;
        }

        return this.props.children;
    }
}

Затем оберните ту часть интерфейса, которую хотите изолировать:

function App() {
    return (
        <>
            <Header />

            <ErrorBoundary>
                <Dashboard />
            </ErrorBoundary>
        </>
    );
}

Если Dashboard или один из его потомков выбросит исключение во время отрисовки, React покажет запасной интерфейс, а не потеряет весь этот раздел.

Но Error Boundary — не универсальный try/catch.

Например, ошибкам из обработчика события всё равно нужна собственная обработка:

1
2
3
4
5
6
7
8
async function handleSave() {
    try {
        await saveDocument();
    } catch (error) {
        reportError(error);
        setMessage('Не удалось сохранить документ.');
    }
}

Используйте Error Boundaries для тех сбоев, которые они предназначены изолировать, а операционные асинхронные сбои обрабатывайте там, где эти операции происходят.

9. Считайте обработчики процесса Node.js аварийными выходами

Сервис на Node.js может слушать ошибки, которые ушли от обычной обработки приложения.

1
2
3
4
5
6
7
process.on('uncaughtException', (error) => {
    console.error('Необработанное исключение:', error);

    reportError(error);

    process.exit(1);
});

Можно также наблюдать необработанные отказы промисов:

1
2
3
4
5
process.on('unhandledRejection', (reason) => {
    console.error('Необработанный отказ:', reason);

    reportError(reason);
});

Важно и то, чего туда класть не стоит.

Не пытайтесь продолжать обычную бизнес-логику из uncaughtException.

Необработанное исключение означает, что приложение пришло в состояние, которое обычный поток управления не учитывал. В зависимости от того, что сломалось, внутреннему состоянию уже нельзя доверять.

Обработчики уровня процесса поэтому полезны для финального логирования, отчёта и контролируемого завершения.

В продакшене менеджер процессов, контейнерная платформа или система оркестрации затем может запустить новый процесс.

Обработчики запросов по-прежнему должны разбирать ожидаемые сбои локально.

1
2
3
4
5
6
7
8
9
app.get('/users/:id', async (req, res, next) => {
    try {
        const user = await getUser(req.params.id);

        res.json(user);
    } catch (error) {
        next(error);
    }
});

Хуки уровня процесса — страховочная сеть под этой архитектурой, а не её замена.

10. Используйте опциональную цепочку для отсутствующих данных, а не для сломанных допущений

Опциональная цепочка и оператор нулевого слияния могут предотвратить лишние сбои, когда отсутствие данных ожидаемо.

const displayName = user?.profile?.displayName ?? 'Аноним';

Это гораздо чище, чем проверять каждый уровень вручную:

1
2
3
4
5
let displayName = 'Аноним';

if (user && user.profile && user.profile.displayName) {
    displayName = user.profile.displayName;
}

Но защитный синтаксис может и скрывать баги, если применять его слишком агрессивно.

Рассмотрим такой код:

const total = order?.payment?.total ?? 0;

Отсутствие платежа действительно должно означать $0?

Возможно.

А возможно, у каждого завершённого заказа обязаны быть платёжные данные, и их отсутствие говорит о повреждённых или неполных данных.

В таком случае полезнее упасть явно:

1
2
3
4
5
6
7
function getOrderTotal(order) {
    if (!order.payment) {
        throw new Error('У заказа нет платёжных данных');
    }

    return order.payment.total;
}

Опциональная цепочка отвечает на вопрос:

Что должно произойти, если этого значения нет?

Обработка ошибок отвечает на другой:

Что должно произойти, если эта операция не может корректно продолжиться?

Они хорошо работают вместе, но решают разные задачи.

Практическая стратегия обработки ошибок

Отдельные паттерны важны, но согласованность важнее.

Для большинства приложений полезный подход выглядит примерно так:

Ситуация Что делать
Ожидаемое отсутствие значения вернуть null, undefined или Result
Восстановимый сбой операции поймать локально и показать запасной вариант
Неожиданный сбой нижнего уровня добавить контекст и пробросить
Сбой отрисовки Error Boundary
Необработанный сбой приложения глобальный отчёт
Невосстановимый сбой процесса Node.js лог, отчёт, остановка, перезапуск

try/catch не нужен вокруг каждой функции.

Нужно ясное владение сбоем.

Функция разбора может бросить исключение, потому что из некорректного ввода нельзя получить результат. Функция поиска может вернуть null, потому что пустой результат ожидаем. Обработчик в интерфейсе может поймать сетевую ошибку, потому что знает, как рассказать пользователю, что произошло.

Эти решения образуют контракт обработки ошибок вашего приложения.

Итог

Хорошая обработка ошибок почти незаметна, пока всё работает.

Её ценность проявляется, когда что-то ломается.

Вместо пустого экрана пользователь получает запасной интерфейс. Вместо необъяснимого бага в продакшене вы получаете стек вызовов с контекстом. Вместо того чтобы каждая функция изобретала собственное соглашение о сбоях, вызывающий код знает, ждать ли null, отклонённый промис или конкретный тип ошибки.

Цель не в том, чтобы поймать каждую возможную ошибку.

Цель в том, чтобы сбои были предсказуемыми.

Ловите ошибки там, где от них действительно можно восстановиться. Добавляйте контекст, когда восстановиться нельзя. Сохраняйте исходную причину, избегайте молчаливых блоков catch и оставляйте глобальные обработчики последней страховочной сетью.

Так вы получаете кое-что полезнее кода, который просто «не падает»: код, который говорит, что пошло не так, когда это всё-таки случается.

Источник: https://jsdevspace.substack.com/p/10-javascript-error-handling-patterns