Ошибки происходят в любом приложении. Говоря об ошибках, первым делом отметим, что все они делятся на два типа: ожидаемые ошибки, обусловленные бизнес-логикой, и неожиданные ошибки. Это различие очень важное, поскольку стратегии обработки ошибок первого и второго типа значительно отличаются.

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

Пример ожидаемой ошибки, обусловленной бизнес-логикой — попытка получить объект из хранилища больших неструктурированных данных (blob storage) с последующей необходимостью обработать случай «объект не найден». Другой пример связан с регистрацией пользователя, когда клиент пытается взять себе логин, который уже занят. В принципе, это ожидаемая ситуация и, если она произойдёт, мы вернем пользователю качественное сообщение об ошибке.

Неожиданные ошибки — такие, которые можно себе представить, но просто их не ожидаешь в условиях нормальной эксплуатации системы. Теоретически, можно было бы попробовать смоделировать все возможные ошибки, но это титаническая работа, сама по себе не слишком полезная. Как правило, не существует способов качественно обрабатывать такие ошибки или как следует после них восстанавливаться.

В качестве примера рассмотрим соединяемость по сети в бэкенд-системах. Как правило, мы рассчитываем на то, что наши серверы будут связаны по сети с другими системами. Если по какой-то причине такая связь оборвётся, то мы почти ничем не сможем поправить ситуацию. В данном случае вполне приемлемо было бы сообщить об «аварийном завершении». Другой пример — ввод-вывод с диска. Когда мы записываем файл на диск, мы обычно рассчитываем, что на диске найдётся достаточно места, чтобы этот файл там поместился.

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

Например, если вы пишете систему Интернета Вещей, которую предполагается развёртывать в районе с плохим покрытием сетью, то, пожалуй, неправильно классифицировать проблемы с доступом к Интернету как «неожиданные». То же касается и приведённого выше примера с записью на диск. Если вы разрабатываете файловый менеджер или пишете код, в котором активно приходится манипулировать файлами (например, систему агрегации логов), то неправильно классифицировать как неожиданные такие ошибки, которые связаны с нехваткой дискового пространства.

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

Выбрасывание ошибок

Наиболее традиционный и простой способ реализовать выявление и обработку ошибок связан с использованием конструкций throw и try/catch. Многие программисты хотя бы однажды на протяжении карьеры сталкивались с этим паттерном. Простой пример такого рода может выглядеть как-то так:

try {
  const user = registerUser();
  return { status: 200, user };
} catch (e) {
  if (e instanceof UserNameTaken) {
    return { status: 400, message: 'User name taken' };
  }
  throw e;
}

С таким подходом связаны две основные проблемы:

1.    Он требует знать все возможные ошибки, которые могут быть выброшены

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

Как узнать обо всех возможных ошибках

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

Скачкообразные переходы в потоке управления

Поток управления — это тот порядок, в котором выполняются инструкции из вашей программы. При выбросе ошибки поток управления как раз перескакивает между разными участками программы, а не выполняет инструкции по порядку, одну за другой. Когда человеку, читающему ваш код, встретится ошибка типа throw, ему будет сложно найти, где именно будет обрабатываться эта ошибка (если она вообще должна обрабатываться). Придётся проследить стек вызовов в восходящем направлении и найти все блоки catch, чтобы определить, какая инструкция будет выполняться после каждого throw. Если подобным скачком может завершиться вызов практически любого метода, то читать и понимать код может быть очень сложно. Например, представьте, что вам нужно сделать ревью следующего фрагмента кода:

async function registerUser(user: User, workspaceId: string) {
  const createdUser = await createUser(user); // 1
  await addUserToWorkspace(createdUser, workspaceId); // 2
  await sendWelcomeEmail(createdUser); // 3
  return createdUser;

Какая строка кода должна выполняться после строки 1? Зависит от того, выбросит ли ошибку функция createUser. Если выбросит, то нам придётся пуститься по следу и отыскать в стеке вызовов нужный нам блок catch. Если не выбросит, то после строки 1 будет выполняться строка 2, а в зависимости от того, выбрасывает ли ошибку addUserToWorkspace, вслед за строкой 2 может выполниться строка 3 Но, чтобы это выяснить, нужно полностью прочитать реализацию функции!

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

Функциональный подход

Такой более функциональный подход заключается в следующем: сам факт, что функция может выдать ошибку, запрограммируем в виде возвращаемого типа. Таким образом, можем возвращать не просто тип User, а такой тип, который может принимать значение User или Error.

В TypeScript этот тип может выглядеть примерно так:

type UserResult = User | Error;

Для начала хорошо, но именно в вызывающем коде может быть несколько сложно обрабатывать этот тип. Нам потребуется проверять каждый экземпляр типа Error, чтобы узнать, ошибка ли это. При обращении с обычными объектами (не относящимися к классам) это было бы ещё сложнее, поскольку потребовалось бы как-то отличать друг от друга два объекта, пользуясь каким-то их уникальным свойством. Обычно для этого лучше использовать размеченные объединения. Поэтому, если обернуть тип в такую форму и добавить к нему различитель, то получим:

type UserResult =
  | { result: 'success', user: User }
  | { result: 'error', error: Error };

function createUser(newUser: NewUser): UserResult {
  return { ... };
}

// Вызывающий код:
const userResult: UserResult = createUser(newUser);

if (userResult.result === 'error') {
  console.error(`Failed to create user: ${userResult.error}`);
  return;
}

Отлично, теперь давайте обобщим этот код, чтобы он мог работать с любыми User и Error, которые мы получаем:

type Result<T, E> = { result: 'success'; value: T } | { result: 'error'; error: E };

True-myth

True-myth — это крошечная библиотека, в которой содержится два основных типа: Maybe и Result.

Рассмотрение типа Maybe выходит за рамки этой статьи, но скажу, что он помогает обращаться со значениями null. Также для maybe предусмотрены удобные варианты преобразования в Result, благодаря чему не составляет труда превращать эти типы друг в друга. Эту возможность также рекомендую внимательно изучить!

Тип Result очень похож на тот, что мы реализовали выше и включает два подтипа: Ok и Err. Они легко конструируются так, как показано в следующем примере кода.

import { Result } from 'true-myth';

type NewUser = { username: string };
type User = { username: string };

export function createUser(newUser: NewUser): Result<User, Error> {
  if (Math.random() > 0.5) {
    return Result.ok(newUser);
  } else {
    return Result.err(new Error('Username already taken'));
  }
}

function handleApiCall() {
  const userResult = createUser({ username: 'hunter2' });
  if (userResult.isErr) {
    return {
      status: 400,
      message: userResult.error.message,
    };
  } else {
    return {
      status: 200,
      user: userResult.value,

В True-myth содержится ещё много приятных утилит, упрощающих обработку типов Result. Например, при помощи match можно одновременно учитывать случаи Ok и Err в одном выражении:

const userResult = createUser({ username: 'hunter2' });
return userResult.match({
  Ok: (user) => ({
    status: 200,
    user,
  }),
  Err: (error) => ({
    status: 400,
    message: error.message,
  }),
});

Ошибки, связанные с предметной областью

Взяв на вооружение тип Result, вы, естественно, захотите более подробно смоделировать ошибки, связанные с вашей бизнес-логикой — например, применив размеченное объединение. Мы разработали для этого тип DomainError, представляющий собой размеченное объединение всех тех ошибок, которые могут случиться в нашей предметной области.

type NotFoundDomainError = {
  code: 'not_found';
  message: string;
  payload: {
    entityId: string;
    entityType: string;
  };
};

type UsernameTakenError = {
  code: 'username_taken';
  message: string;
  payload: {
    username: string;
  };
};

type InternalError = {
  code: 'internal_error';
  message: 'Unknown error';
};

type DomainError = NotFoundDomainError | UsernameTakenError | InternalError;

Явно моделируя важные для нас ошибки, мы решаем следующие задачи:

1. Наши функции возвращают сообщения об ошибках с высокой детализацией, и тем самым возникающие ошибки одновременно документируются

2.Обработка ошибок получается более специфичной

Например, наша функция createUser может возвращать UsernameTakenError и InternalError, информируя таким образом вызывающую функцию о конкретных случаях отказа. Так функция получается более надёжной и удобной для переиспользования. Именно эту функцию мы вызываем и в интеграционных тестах, но обрабатываем её иначе.

В нашем API мы преобразуем эти ошибки предметной области в ошибки-отклики. В таком случае нам становится удобнее более подробно обработать каждую из этих ошибок в следующей функции handleApiCall. Компилятор TypeScript проверяет все эти случаи и обеспечивает правильную обработку userResult.error.code. Таким образом, в дальнейшем мы можем обрабатывать и другие ошибки, относящиеся к предметной области.

export function createUser(newUser: NewUser): Result<User, UsernameTakenError | InternalError> {
  // Иногда здесь возникает ошибка
  if (Math.random() > 0.5) {
    return Result.ok(newUser);
  } else {
    return Result.err({
      code: 'username_taken',
      message: 'Username already taken, please choose another one.',
      payload: {
        username: newUser.username,
      },
    });
  }
}

function handleApiCall() {
  const userResult = createUser({ username: 'hunter2' });
  if (userResult.isErr) {
    switch (userResult.error.code) {
      case 'username_taken':
        return {
          status: 400,
          message: userResult.error.message,
        };
      case 'internal_error':
        return {
          status: 500,
        };
    }
  }
  const user = userResult.value;
  return {
    status: 200,
    user,
  };
}

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

Заключение

В этом посте мы рассмотрели примеры на TypeScript, но такой же подход применим и в большинстве других языков. Аналогичный паттерн попадался мне при работе с типом Either из Scala, где тип Left обозначает ошибку бизнес-логики, а Right — результат успешного выполнения.

Придерживаясь такой более функциональной стратегии, можно обрабатывать ошибки гораздо более точно и детально. Библиотека true-myth прямо из коробки предоставляет отличные типы и вспомогательные функции, поэтому приступить к работе с ней очень легко. Конечно, когда пишешь код в таком стиле, возникают небольшие издержки, но они того стоят.

Комментарии (3)


  1. Vitaly_js
    08.08.2025 10:15

    Кстати, вот любопытно, вы как то сразу перескочили на использование зоопарка из разных ответов, но по моему в воздухе повис вопрос: а когда предпочтительнее использовать такой вот зоопарк вместо исключений?

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

    declare class UserError extends Error {
      code: 'username_taken'
    }
    
    function handleApiCall() {
      try {
        const user = createUser({ username: 'hunter2' }).value;
    
        return {
          status: 200,
          user,
        }
      } catch(e: unknown) {
        if (e instanceof UserError) {
          switch (e.code) {
            case 'username_taken':
              return {
                status: 400,
                message: e.message,
              };          
        }
    
        return {
          status: 500,
        };
      }
    }


  1. fransua
    08.08.2025 10:15

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