Как делать11 октября 2026 г.6 мин чтения
JavaScript с нуляШаг 7 из 9

Обработка ошибок: try/catch, async-ошибки и понятные сообщения

Коротко

Ошибку обрабатывают там, где о ней есть что сказать: низкоуровневый код бросает конкретную ошибку своего класса и сохраняет исходную в cause, а граница — обработчик запроса, экран, команда — ловит её, пишет подробности в лог и показывает пользователю короткое сообщение о том, что делать. В async-коде каждый промис должен быть дождан через await или иметь .catch(): в Node.js необработанное отклонение по умолчанию завершает процесс. Пустой catch, который глотает ошибку, хуже падения.

ОсновыУровень: СреднийОбработка ошибок

После материала вы сможете

  • обработать синхронную и асинхронную ошибку через try/catch/finally и перебросить ту, которую код не умеет обработать
  • создать свои классы ошибок и обернуть исходную ошибку через Error.cause, не теряя её
  • объяснить, почему «висящий» промис роняет процесс Node.js, и найти такой промис по выводу
  • разделить сообщение пользователю и запись в лог так, чтобы технические детали не уходили наружу

Проверено на

Node.js 24 LTS (24.18)JavaScript (ES2022+: Error.cause)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Ошибка как значение и как исключение
  2. 02throw, try/catch/finally
  3. 03Свои классы ошибок и Error.cause
  4. 04Ошибки в промисах и async/await
  5. 05Где ловить: на границе, а не везде
  6. 06Сообщения пользователю против логов разработчика
  7. 07Антипаттерны и упражнения

Ошибка как значение и как исключение

В JavaScript есть два способа сообщить, что что-то пошло не так. Исключение (exception) прерывает выполнение: throw выбрасывает значение, и оно «всплывает» по стеку вызовов, пока его не поймает ближайший catch. Если не поймает никто, программа падает — в уроке об отладке мы видели, как это выглядит. Второй способ — вернуть ошибку как обычное значение, например объект { ok: false, error }, и оставить решение вызывающему коду.

safe-parse.mjs
export function safeParseJson(text) {
  try {
    return { ok: true, value: JSON.parse(text) };
  } catch (error) {
    return { ok: false, error };
  }
}

const results = ['{"qty": 2}', "{qty: 2}"].map(safeParseJson);
for (const result of results) {
  if (result.ok) console.log("value:", result.value);
  else console.log("invalid JSON:", result.error.message);
}
Ошибка как значение: вызывающий код обязан проверить ok
terminal
$ node safe-parse.mjs
value: { qty: 2 }
invalid JSON: Expected property name or '}' in JSON at position 1 (line 1 column 2)
Реальный вывод Node.js 24

Вернуть значение { ok, error }

Когда подходит
Ошибка — ожидаемый исход: пользователь ввёл неверные данные, файла может не быть
Риск
Результат забыли проверить, и ошибка прошла дальше

Бросить исключение

Когда подходит
Функция не может выполнить свою работу, и решать должен кто-то выше по стеку
Риск
Исключение никто не поймал, и программа упала

Встроенные API JavaScript и Node.js в основном бросают исключения, поэтому дальше речь в основном о них. Бросать стоит объект Error или его наследника, а не строку: только у Error есть stack, name и cause.

throw, try/catch/finally

try содержит код, который может бросить исключение; catch получает брошенное значение; finally выполняется всегда — после успешного try, после catch и даже если внутри был return. В JavaScript нет типизированных catch, как в Java, поэтому тип проверяют сами через instanceof и перебрасывают всё, что не умеют обработать:

basics.mjs
function parseQty(input) {
  const qty = Number(input);
  if (!Number.isInteger(qty) || qty < 1) {
    throw new RangeError(`qty must be a positive integer, got "${input}"`);
  }
  return qty;
}

for (const input of ["2", "0", "abc"]) {
  try {
    console.log("try:", parseQty(input));
  } catch (error) {
    if (!(error instanceof RangeError)) throw error;
    console.log("catch:", error.name, "-", error.message);
  } finally {
    console.log("finally: input", JSON.stringify(input), "checked");
  }
}
Ловим только ожидаемый RangeError, остальное пробрасываем выше
terminal
$ node basics.mjs
try: 2
finally: input "2" checked
catch: RangeError - qty must be a positive integer, got "0"
finally: input "0" checked
catch: RangeError - qty must be a positive integer, got "abc"
finally: input "abc" checked
Реальный вывод: finally выполняется в каждой итерации
  • finally — место для уборки: закрыть файл или соединение, выключить индикатор загрузки, снять блокировку кнопки.
  • Не пишите return или throw внутри finally: MDN предупреждает, что они перекрывают результат try и catch, и брошенная ошибка может молча исчезнуть.
  • Если значение ошибки не нужно, используйте catch { … } без скобок — но только когда вы действительно знаете, что произошло (как в safeParseJson).
  • Пойманная ошибка обрабатывается один раз — ближайшим catch. Чтобы её увидел и внешний обработчик, бросьте её снова: throw error.

Свои классы ошибок и Error.cause

Встроенные TypeError или SyntaxError говорят, что сломалось технически, но не говорят, что это значит для приложения. Свои классы ошибок дают обработчику опору для решения: «нет связи — предложить повторить», «сервер ответил 500 — показать заглушку». MDN рекомендует наследоваться от Error и проверять тип через instanceof. Строка this.name = new.target.name записывает в name имя реального класса — оно попадёт в лог и в stack trace.

errors.mjs
export class AppError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = new.target.name;
  }
}

export class NetworkError extends AppError {}

export class HttpError extends AppError {
  constructor(status, url, options) {
    super(`HTTP ${status} for ${url}`, options);
    this.status = status;
  }
}

export class DataError extends AppError {}
Иерархия ошибок приложения: общий AppError и три конкретных случая

Второй аргумент конструктора Error — объект опций с полем cause. Он нужен, когда вы ловите низкоуровневую ошибку и бросаете вместо неё понятную: исходная не теряется и остаётся доступна в error.cause. Так устроена функция загрузки меню кофейни — каждый способ сломаться превращается в свой класс:

load-menu.mjs
import { DataError, HttpError, NetworkError } from "./errors.mjs";

export async function loadMenu(url) {
  let res;
  try {
    res = await fetch(url, { signal: AbortSignal.timeout(3000) });
  } catch (error) {
    throw new NetworkError(`Request to ${url} failed`, { cause: error });
  }

  if (!res.ok) throw new HttpError(res.status, url);

  let data;
  try {
    data = await res.json();
  } catch (error) {
    throw new DataError(`Response from ${url} is not JSON`, { cause: error });
  }

  if (!Array.isArray(data.items)) {
    throw new DataError(`Response from ${url} has no "items" array`);
  }
  return data.items;
}
Каждый вид сбоя — свой класс; исходная ошибка сохраняется в cause

Цепочку причин можно пройти циклом. Запрос к порту, на котором никто не слушает, даёт три уровня: наша NetworkError, TypeError: fetch failed из fetch и системная ошибка с кодом ECONNREFUSED:

cause-chain.mjs
import { loadMenu } from "./load-menu.mjs";

try {
  await loadMenu("http://127.0.0.1:8082/menu");
} catch (error) {
  let current = error;
  while (current) {
    console.log(`${current.name}: ${current.message}${current.code ? ` (${current.code})` : ""}`);
    current = current.cause;
  }
}
Проход по цепочке cause
terminal
$ node cause-chain.mjs
NetworkError: Request to http://127.0.0.1:8082/menu failed
TypeError: fetch failed
Error: connect ECONNREFUSED 127.0.0.1:8082 (ECONNREFUSED)
Реальный вывод: три уровня причины

Ошибки в промисах и async/await

Внутри async-функции throw превращается в отклонённый промис (rejected promise). await разворачивает его обратно в исключение, поэтому обычный try/catch вокруг await ловит асинхронные ошибки так же, как синхронные. Проблемы начинаются, когда промис никто не ждёт. Функция ниже запускает загрузку и забывает про неё — промис остаётся «висящим» (floating):

floating.mjs
import { loadMenu } from "./load-menu.mjs";

function refreshMenu() {
  loadMenu("http://127.0.0.1:8082/menu");
  console.log("refresh started");
}

refreshMenu();
setTimeout(() => console.log("next refresh in 1 s"), 1000);
Ошибка: loadMenu вызван без await и без .catch()
terminal
$ node floating.mjs
refresh started
file:///…/load-menu.mjs:8
    throw new NetworkError(`Request to ${url} failed`, { cause: error });
          ^

NetworkError: Request to http://127.0.0.1:8082/menu failed
    at loadMenu (file:///…/load-menu.mjs:8:11)
    at process.processTicksAndRejections (node:internal/process/task_queues:104:5) {
  [cause]: [TypeError: fetch failed] {
    [cause]: Error: connect ECONNREFUSED 127.0.0.1:8082
        at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1706:16) {
      errno: -4078,
      code: 'ECONNREFUSED',
      syscall: 'connect',
      address: '127.0.0.1',
      port: 8082
    }
  }
}

Node.js v24.18.0
Реальный вывод: процесс упал, таймер так и не сработал; путь сокращён до …

Три вещи в этом выводе. Первая: refresh started напечаталось — функция «успешно» завершилась раньше, чем запрос упал. Вторая: строка next refresh in 1 s не появилась — процесс завершился с кодом 1. С Node.js 15 режим --unhandled-rejections по умолчанию равен throw: необработанное отклонение превращается в неперехваченное исключение. Третья: Node.js печатает всю цепочку [cause] — вот зачем её сохранять. Обратите внимание, что в стеке нет refreshMenu: после await стек показывает только асинхронную часть, поэтому хорошее сообщение ошибки (с URL) ценнее длинного стека.

fixed-refresh.mjs
import { loadMenu } from "./load-menu.mjs";
import { toUserMessage } from "./user-message.mjs";

async function refreshMenu() {
  try {
    const items = await loadMenu("http://127.0.0.1:8082/menu");
    console.log("menu:", items.join(", "));
  } catch (error) {
    console.error("[log]", error.name, error.message);
    console.log("User sees:", toUserMessage(error));
  }
}

await refreshMenu();
setTimeout(() => console.log("next refresh in 1 s"), 1000);
Исправление: await внутри try/catch
terminal
$ node fixed-refresh.mjs
[log] NetworkError Request to http://127.0.0.1:8082/menu failed
User sees: No connection to the server. Check your internet and try again.
next refresh in 1 s
Реальный вывод: ошибка обработана, программа продолжает работу

Последняя страховка — глобальный обработчик process.on("unhandledRejection") в Node.js или событие unhandledrejection в браузере. Он нужен, чтобы записать в лог то, что пропустили, а не чтобы «продолжить как ни в чём не бывало»:

safety-net.mjs
process.on("unhandledRejection", (reason) => {
  console.error("[fatal] unhandled rejection:", reason?.name, reason?.message);
  console.error("[fatal] cause:", reason?.cause?.message ?? "-");
  process.exitCode = 1;
});

await import("./floating.mjs");
Глобальный обработчик: лог и ненулевой код выхода
terminal
$ node safety-net.mjs; echo "exit=$?"
refresh started
[fatal] unhandled rejection: NetworkError Request to http://127.0.0.1:8082/menu failed
[fatal] cause: fetch failed
next refresh in 1 s
exit=1
Реальный вывод: с обработчиком процесс не упал, но завершился с кодом 1

Где ловить: на границе, а не везде

Частая ошибка — оборачивать в try/catch каждую функцию. Код раздувается, а ошибки всё равно теряются или логируются по пять раз. Работает другое правило: ловить там, где есть что сделать с ошибкой. Внутренние функции бросают понятные ошибки и не решают, что показать пользователю. Решение принимает граница — место, где код встречается с внешним миром: обработчик HTTP-запроса, обработчик кнопки, точка входа команды или фоновой задачи.

fetch падаетloadMenu: NetworkError с causeграница: catchлог: всё с деталямипользователь: что делать
Ошибка поднимается без промежуточных catch и обрабатывается один раз — на границе.

Соберём всё вместе. Тестовый сервер отдаёт три ответа — нормальное меню, ошибку 500 и HTML вместо JSON, — а четвёртый адрес указывает на порт, где никого нет. Граница — цикл в main.mjs: один try/catch/finally на каждую загрузку.

test-server.mjs
import { createServer } from "node:http";

export function startTestServer(port) {
  const server = createServer((req, res) => {
    if (req.url === "/menu") {
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ items: ["latte", "flat white"] }));
    }
    if (req.url === "/broken") {
      res.writeHead(500, { "Content-Type": "text/plain" });
      return res.end("Internal Server Error");
    }
    if (req.url === "/html") {
      res.writeHead(200, { "Content-Type": "text/html" });
      return res.end("<!doctype html><h1>Maintenance</h1>");
    }
    res.writeHead(404).end();
  });
  return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
}
Тестовый сервер на node:http — только для проверки сценариев
main.mjs
import { loadMenu } from "./load-menu.mjs";
import { toUserMessage } from "./user-message.mjs";
import { startTestServer } from "./test-server.mjs";

const server = await startTestServer(8081);
const urls = [
  "http://127.0.0.1:8081/menu",
  "http://127.0.0.1:8081/broken",
  "http://127.0.0.1:8081/html",
  "http://127.0.0.1:8082/menu",
];

for (const url of urls) {
  console.log("→", url, "(spinner on)");
  try {
    const items = await loadMenu(url);
    console.log("  OK:", items.join(", "));
  } catch (error) {
    console.error("  [log]", error.name, error.message, "| cause:", error.cause?.message ?? "-");
    console.log("  User sees:", toUserMessage(error));
  } finally {
    console.log("  spinner off");
  }
}

server.close();
Граница: лог для разработчика, сообщение для пользователя, finally для уборки
terminal
$ node main.mjs
→ http://127.0.0.1:8081/menu (spinner on)
  OK: latte, flat white
  spinner off
→ http://127.0.0.1:8081/broken (spinner on)
  [log] HttpError HTTP 500 for http://127.0.0.1:8081/broken | cause: -
  User sees: The menu is temporarily unavailable. Please try again in a minute.
  spinner off
→ http://127.0.0.1:8081/html (spinner on)
  [log] DataError Response from http://127.0.0.1:8081/html is not JSON | cause: Unexpected token '<', "<!doctype "... is not valid JSON
  User sees: The menu could not be displayed. We are already looking into it.
  spinner off
→ http://127.0.0.1:8082/menu (spinner on)
  [log] NetworkError Request to http://127.0.0.1:8082/menu failed | cause: fetch failed
  User sees: No connection to the server. Check your internet and try again.
  spinner off
Реальный вывод: четыре сценария, программа не упала ни разу

Сообщения пользователю против логов разработчика

У одной ошибки два читателя с разными задачами. Разработчику нужны подробности: класс, текст, URL, цепочка cause, stack trace. Пользователю нужно понять, что случилось с его задачей и что делать дальше. OWASP в Error Handling Cheat Sheet рекомендует не отдавать наружу детали реализации — стеки, SQL, пути к файлам, версии библиотек помогают атакующему, — а писать их в лог на сервере. Перевод технической ошибки в человеческие слова удобно держать в одной функции:

user-message.mjs
import { DataError, HttpError, NetworkError } from "./errors.mjs";

export function toUserMessage(error) {
  if (error instanceof NetworkError) {
    return "No connection to the server. Check your internet and try again.";
  }
  if (error instanceof HttpError && error.status >= 500) {
    return "The menu is temporarily unavailable. Please try again in a minute.";
  }
  if (error instanceof HttpError && error.status === 404) {
    return "Menu not found. Please refresh the page.";
  }
  if (error instanceof DataError) {
    return "The menu could not be displayed. We are already looking into it.";
  }
  return "Something went wrong. Please try again later.";
}
Одна функция переводит класс ошибки в сообщение для пользователя

Содержание

Лог разработчика
Класс, текст, URL, статус, цепочка cause, stack trace
Сообщение пользователю
Что случилось с задачей пользователя и что сделать

Язык

Лог разработчика
Технический, на английском — удобно искать
Сообщение пользователю
Язык интерфейса, без терминов

Детали реализации

Лог разработчика
Да, полностью
Сообщение пользователю
Нет: ни стека, ни SQL, ни путей к файлам

Пример

Лог разработчика
HttpError HTTP 500 for http://…/broken
Сообщение пользователю
«The menu is temporarily unavailable…» — в примере сообщения на английском, в реальном интерфейсе они на языке пользователя

Хорошее сообщение пользователю отвечает на три вопроса: что не получилось, по чьей вине (его или нашей) и что делать — повторить, проверить интернет, обновить страницу. Если ошибка в данных пользователя, покажите её рядом с полем. Для HTTP API формат ответа с ошибкой стандартизирован в RFC 9457 (Problem Details): тип, заголовок, статус и описание — без внутренностей сервера.

Антипаттерны и упражнения

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

antipattern.mjs
async function saveOrder(order) {
  throw new Error(`DB is read-only, order ${order.id} not saved`);
}

const order = { id: 42 };
try {
  await saveOrder(order);
} catch {}
console.log("Order accepted!");
Антипример: пустой catch глотает ошибку сохранения
terminal
$ node antipattern.mjs
Order accepted!
Реальный вывод: заказ не сохранён, но пользователь видит «принят», код выхода 0
  • Нет пустых catch и catch, который только пишет console.log(error) и продолжает работу как ни в чём не бывало
  • Бросаются объекты Error и их наследники, а не строки
  • При переупаковке исходная ошибка сохраняется в cause
  • Ошибка логируется один раз — на границе, а не на каждом уровне
  • Каждый промис дождан через await, возвращён или имеет .catch()
  • Ответ fetch проверяется через res.ok — 404 и 500 не бросают исключение сами
  • В finally нет return и throw
  • Пользователь видит понятное сообщение без стека и деталей реализации
Как проверено7 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    safe-parse.mjs, basics.mjs

    node на Node.js 24.18.0 (Windows 11), вывод вставлен реальный

  • запущено, работает

    errors.mjs + load-menu.mjs + user-message.mjs + test-server.mjs + main.mjs

    node main.mjs: тестовый сервер 127.0.0.1:8081 (200 / 500 / HTML), четвёртый адрес — свободный порт 8082 (ECONNREFUSED); вывод реальный

  • запущено, работает

    cause-chain.mjs

    реальная цепочка NetworkError → TypeError: fetch failed → ECONNREFUSED

  • запущено, работает

    floating.mjs (необработанное отклонение)

    процесс упал с кодом 1, таймер не сработал; вывод реальный, путь к папке заменён на «…»

  • запущено, работает

    safety-net.mjs, fixed-refresh.mjs

    реальный вывод; safety-net — exit=1 с продолжением работы, fixed-refresh — exit=0

  • запущено, работает

    antipattern.mjs

    реальный вывод «Order accepted!», exit=0

  • запущено, работает

    Упражнения: 404-сценарий, сообщение при таймауте, loadConfig

    запуск в копии проекта: /missing → HttpError 404; сервер без ответа → TimeoutError в cause и новое сообщение, отказ соединения — прежнее; loadConfig на good/bad/missing.json — вывод реальный

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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