Перейти к содержимому

49. Error Handling в TypeScript

TypeScript: Обработка Ошибок (Error Handling) как Профи

Заголовок раздела «TypeScript: Обработка Ошибок (Error Handling) как Профи»

Привет, кодер! С тобой Яша, и сегодня мы погрузимся в одну из самых критичных, но часто недооцененных тем в разработке — обработку ошибок. В мире, где код взаимодействует с внешними API, пользовательским вводом и непредсказуемыми условиями, ошибки — это не баги, а скорее неизбежные спутники. И наш долг — встречать их во всеоружии!

TypeScript, с его мощной системой типов, дает нам уникальные инструменты для того, чтобы делать обработку ошибок не просто функциональной, но и типобезопасной. Забудь о неопределенных any в блоках catch; пришло время обрабатывать ошибки так же элегантно, как и любой другой тип данных.

В этом уроке мы разберем:

  • Как try...catch работает с unknown в TypeScript.
  • Как создавать свои, выразительные типы ошибок.
  • Продвинутые паттерны, такие как Result тип, для явного управления ошибками.
  • Типичные подводные камни и как их обходить.

Готов? Поехали!

В JavaScript, блок catch традиционно ловит что угодно, что было выброшено (thrown). До TypeScript 4.0 переменная в catch блоке по умолчанию имела тип any. Это было удобно, но опасно, ведь мы теряли всякую типобезопасность.

Начиная с TypeScript 4.0 (и с флагом useUnknownInCatchVariables или strict mode), переменная в catch блоке по умолчанию имеет тип unknown. Это гораздо безопаснее! Почему? Потому что unknown заставляет нас явно проверять тип пойманной ошибки, прежде чем мы сможем что-то с ней сделать. Это как если бы тебе пришла посылка без этикетки – ты же не будешь ее сразу открывать и есть, не зная, что внутри, верно? Ты сначала ее проверишь!

function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Деление на ноль невозможно!"); // Выбрасываем стандартную ошибку
}
return a / b;
}
try {
const result = divide(10, 0);
console.log(`Результат: ${result}`);
} catch (error: unknown) { // error теперь имеет тип unknown
if (error instanceof Error) {
// Мы уверены, что это объект Error, и можем безопасно получить доступ к его свойствам
console.error(`Ошибка при делении: ${error.message}`);
} else {
// Это что-то совершенно другое, не объект Error
console.error("Произошла неизвестная ошибка:", error);
}
}
// Пример с другим типом выброшенного значения (TypeScript все равно поймает как unknown)
function riskyOperation(shouldThrowString: boolean): void {
if (shouldThrowString) {
throw "Что-то пошло не так как строка!"; // Можем выбросить что угодно
}
console.log("Операция завершена успешно.");
}
try {
riskyOperation(true);
} catch (error: unknown) {
if (typeof error === 'string') {
console.error(`Поймана строка-ошибка: "${error}"`);
} else if (error instanceof Error) {
console.error(`Поймана ошибка Error: ${error.message}`);
} else {
console.error("Неизвестный тип ошибки:", error);
}
}

Использование unknown и instanceof или typeof для сужения типа ошибки — это золотой стандарт в современном TypeScript.

Стандартный Error хорош, но часто нам нужно больше контекста. Например, ошибка сети отличается от ошибки валидации данных. Создание собственных классов ошибок позволяет нам:

  1. Добавить специфичные данные: Код ошибки, статус HTTP, поле, вызвавшее ошибку.
  2. Улучшить читаемость: Имя класса сразу говорит о сути проблемы.
  3. Упростить обработку: Мы можем ловить и обрабатывать конкретные типы ошибок.

Всегда наследуйтесь от Error или другого класса ошибок (например, TypeError, RangeError), чтобы сохранить стандартное поведение (stack trace и т.д.).

// Определяем собственный тип для кода ошибки
enum ErrorCode {
ValidationFailed = 'VALIDATION_FAILED',
NetworkError = 'NETWORK_ERROR',
Unauthorized = 'UNAUTHORIZED',
}
// 1. Ошибка валидации данных
class ValidationError extends Error {
public readonly code: ErrorCode = ErrorCode.ValidationFailed;
public readonly field?: string;
constructor(message: string, field?: string) {
super(message); // Вызываем конструктор базового класса Error
this.name = 'ValidationError'; // Устанавливаем имя для идентификации
this.field = field;
// Важно для корректного stack trace в некоторых окружениях
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
// 2. Ошибка сетевого взаимодействия
class NetworkError extends Error {
public readonly code: ErrorCode = ErrorCode.NetworkError;
public readonly statusCode?: number;
constructor(message: string, statusCode?: number) {
super(message);
this.name = 'NetworkError';
this.statusCode = statusCode;
Object.setPrototypeOf(this, NetworkError.prototype);
}
}
// Пример использования
function validateUserData(username: string, email: string): void {
if (username.length < 3) {
throw new ValidationError("Имя пользователя должно быть не менее 3 символов", "username");
}
if (!email.includes('@')) {
throw new ValidationError("Некорректный формат email", "email");
}
console.log("Данные пользователя валидны.");
}
async function fetchData(url: string): Promise<string> {
try {
// Имитируем сетевой запрос
const response = await new Promise<Response>((resolve, reject) => {
setTimeout(() => {
if (url.includes('error')) {
reject(new Response(null, { status: 500, statusText: 'Internal Server Error' }));
} else if (url.includes('unauthorized')) {
reject(new Response(null, { status: 401, statusText: 'Unauthorized' }));
} else {
resolve(new Response('{"data": "some-data"}', { status: 200 }));
}
}, 500);
});
if (!response.ok) {
// Если ответ не OK, выбрасываем NetworkError
throw new NetworkError(`Ошибка HTTP: ${response.statusText}`, response.status);
}
return await response.text();
} catch (err: unknown) {
// Проверяем, если это наш NetworkError (уже выброшенный из-за !response.ok)
if (err instanceof NetworkError) {
throw err; // Перебрасываем нашу специфичную ошибку
}
// Если это была другая ошибка (например, из Promise.reject до создания Response)
if (err instanceof Response) { // Если reject вернул сам Response объект
throw new NetworkError(`Неожиданный сетевой ответ: ${err.statusText}`, err.status);
}
// В противном случае, оборачиваем в стандартную ошибку или перебрасываем как есть
throw new Error(`Неизвестная ошибка при запросе к ${url}: ${err}`);
}
}
// Обработка на верхнем уровне
async function processUserAction() {
try {
validateUserData("ya", "[email protected]"); // Вызовет ValidationError
// validateUserData("yasha", "invalid-email"); // Вызовет ValidationError
// await fetchData("http://api.example.com/data"); // Успех
// await fetchData("http://api.example.com/unauthorized"); // Вызовет NetworkError
} catch (err: unknown) {
if (err instanceof ValidationError) {
console.error(`🚫 Ошибка валидации поля "${err.field}": ${err.message}`);
} else if (err instanceof NetworkError) {
console.error(`🌐 Ошибка сети (${err.statusCode}): ${err.message}`);
} else if (err instanceof Error) {
console.error(`❓ Неожиданная ошибка: ${err.message}`);
} else {
console.error("Критическая неизвестная ошибка:", err);
}
}
}
processUserAction();

🛠️ Продвинутые Приемы: Единообразие и Расширяемость

Заголовок раздела «🛠️ Продвинутые Приемы: Единообразие и Расширяемость»

Иногда использование throw не всегда лучший подход, особенно когда ошибка является ожидаемым результатом операции (например, не найдена запись). В таких случаях, возвращение Result типа может быть более явным и предсказуемым.

Идея проста: функция не throw’ит ошибку, а возвращает объект, который явно указывает, была ли операция успешной или нет, и содержит либо результат, либо ошибку. Это вдохновлено функциональным программированием.

// Тип, представляющий успешный результат или ошибку
type Result<T, E> = { success: true; value: T } | { success: false; error: E };
class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
Object.setPrototypeOf(this, NotFoundError.prototype);
}
}
// Функция, которая "безопасно" ищет пользователя
function findUserById(id: string): Result<{ id: string; name: string }, NotFoundError | Error> {
if (id === '123') {
return { success: true, value: { id: '123', name: 'Алиса' } };
}
if (id === 'error-id') {
return { success: false, error: new Error('Непредвиденная ошибка в базе данных.') };
}
return { success: false, error: new NotFoundError(`Пользователь с ID ${id} не найден.`) };
}
// Использование "безопасной" функции
const userResult = findUserById('123');
if (userResult.success) {
console.log(`Найден пользователь: ${userResult.value.name}`);
} else {
// TypeScript теперь знает, что userResult.error может быть NotFoundError или Error
if (userResult.error instanceof NotFoundError) {
console.warn(`⚠️ Предупреждение: ${userResult.error.message}`);
} else {
console.error(`❌ Критическая ошибка: ${userResult.error.message}`);
}
}
const missingUserResult = findUserById('999');
if (missingUserResult.success) {
console.log(`Найден пользователь: ${missingUserResult.value.name}`);
} else {
console.error(`При поиске: ${missingUserResult.error.message}`);
}

Паттерн Result делает код более декларативным, так как все возможные исходы операции явно указаны в сигнатуре типа.

  1. “Заглатывание” ошибок (Swallowing Errors): Пустой catch блок.

    try {
    riskyOperation(true);
    } catch {
    // 😱 Ничего не делаем! Ошибка просто исчезла.
    }

    Всегда либо логируйте ошибку, либо перебрасывайте (throw) ее, либо явно обрабатывайте.

  2. Недостаточная детализация ошибок: Использование только new Error() без контекста.

    • Решение: Создавайте свои классы ошибок, как мы показали выше, с дополнительными полями (коды, данные).
  3. Неправильное обращение с unknown в catch: Попытка доступа к свойствам без проверки.

    try {
    throw { code: 500, message: "Сервер недоступен" }; // Выбрасываем объект
    } catch (error: unknown) {
    // console.error(error.message); // 💥 Ошибка компиляции: 'error' is of type 'unknown'.
    if (typeof error === 'object' && error !== null && 'message' in error) {
    console.error((error as { message: string }).message); // Только после сужения типа!
    }
    }
    • Решение: Всегда сужайте тип unknown с помощью instanceof, typeof, или пользовательских Type Guards.
  4. Игнорирование ошибок в промисах без await или .catch():

    async function doSomethingAsync() {
    // Этот промис может завершиться ошибкой, но мы не await'им его и не добавляем .catch()
    // Ошибка будет необработанным исключением, если он завершится неудачно.
    Promise.reject("Ошибка в фоновом промисе!");
    }
    // doSomethingAsync(); // Вызовет Unhandled Promise Rejection
    • Решение: Всегда await промисы или добавляйте .catch() для их обработки.
  1. Пользовательский парсер JSON: Создайте класс JsonParsingError, который наследуется от Error и включает свойство originalInput: string. Напишите функцию safeJsonParse<T>(jsonString: string): Result<T, JsonParsingError | Error>, которая будет использовать try...catch для JSON.parse и возвращать Result. Если JSON.parse выбрасывает ошибку, функция должна обернуть ее в JsonParsingError.

  2. Функция авторизации с разными ошибками: Определите два новых класса ошибок: UserNotFoundError и InvalidCredentialsError. Создайте асинхронную функцию authenticate(username: string, passwordHash: string): Promise<Result<{ userId: string }, UserNotFoundError | InvalidCredentialsError | Error>>.

    • Имитируйте поиск пользователя: если username === “admin” и passwordHash === “hashed_password”, верните успех.
    • Если пользователь не найден, верните UserNotFoundError.
    • Если пароль не совпадает, верните InvalidCredentialsError.
    • Имитируйте неожиданную ошибку (например, username === “crash”) и верните стандартный Error.
  3. Декоратор для обработки асинхронных ошибок: (Продвинутое) Создайте декоратор метода @catchAsyncErrors, который будет оборачивать асинхронный метод класса в try...catch. Если метод выбрасывает ошибку, декоратор должен логировать ее и возвращать Promise.reject(error) или, опционально, Promise.resolve(undefined) после обработки ошибки (например, отправки ее в аналитику).

Используйте try...catch для непредвиденных исключений, которые могут нарушить выполнение программы (деление на ноль, проблемы с I/O, неожиданные ответы API). Для ожидаемых ошибок, которые являются частью бизнес-логики (неверный ввод, отсутствие записи, отказано в доступе), рассмотрите паттерн Result или явное возвращение ошибок, чтобы сделать потоки выполнения более прозрачными и типобезопасными. Всегда логируйте ошибки и предоставляйте пользователю осмысленную обратную связь, не раскрывая внутренних деталей реализации.