Хватит гадать, что вернула функция: явный Result в TypeScript
В разработке я очень ценю одну простую вещь: когда результат выполнения функции нельзя трактовать двусмысленно. Не null, не undefined, не “если что-то пошло не так, где-нибудь наверху поймаем исключение”, а нормальный явный результат: success(data) или fault({ code: 'OUTPUT_SCHEMA_ERROR', message: 'Invalid output schema' }). Мне нравится, когда вызывающий код сразу понимает, что произошло: операция либо завершилась успешно и вернула данные, либо завершилась ошибкой и вернула понятную причину.
Идея из Elixir, но без фанатизма
Идею такого подхода я частично подсмотрел в Elixir. Там часто используют кортежи вида {:ok, value} и {:error, reason}. Это очень простая, но сильная идея: функция не возвращает “что-то”, что потом нужно угадывать. Она явно говорит: всё хорошо, вот результат. Или: произошла ошибка, вот причина. Мне нравится сама дисциплина такого подхода. Когда ты работаешь с кодом, особенно не один, такая предсказуемость сильно упрощает жизнь.
В TypeScript я обычно использую похожий подход через Result:
После этого функция может иметь честную сигнатуру:
По этой сигнатуре сразу понятно: функция либо вернёт пользователя, либо вернёт понятную ошибку. Это не сложная архитектура и не попытка натянуть функциональное программирование на весь проект. Это просто небольшой контракт, который делает поведение функции предсказуемым.
Почему я не люблю неявные ошибки
Во многих проектах можно встретить такой стиль: const user = await getUser(id), а потом if (!user) { ... }. На первый взгляд всё нормально, но что означает !user? Пользователь не найден? База данных недоступна? Нет прав доступа? API вернул неожиданный формат? Разработчик забыл обработать ошибку внутри функции? В маленьком проекте это может быть терпимо, но в большом проекте такие места начинают превращаться в источник постоянных багов.
Поэтому мне больше нравится, когда функция возвращает не просто данные или пустоту, а структурированный результат:
Здесь не нужно гадать. Если fault, значит, есть ошибка. Если success, значит, есть данные. Такой код чуть более многословный, зато его проще читать и сопровождать.
TypeScript сам помогает не ошибиться
У этого подхода есть ещё один приятный плюс: TypeScript начинает работать как дополнительная защита. Если Result описан как discriminated union, то TypeScript не даст нормально работать с payload, пока мы не проверим статус результата. Например, такой код должен вызвать ошибку типов:
Сначала нужно сузить тип:
После проверки isFault(result) TypeScript понимает: если мы дошли до следующей строки, значит, перед нами уже Success. Это небольшая вещь, но на практике она очень помогает. Код буквально заставляет разработчика обработать ошибочный сценарий перед тем, как работать с данными.
Внешние данные нельзя принимать на веру
TypeScript хорошо проверяет код, который мы написали сами, но он не проверяет реальность. Если внешний API в документации обещает объект с id: string, email: string и balance: number, это не значит, что завтра в production не придёт id числом, email как null, а balance строкой. И если мы просто напишем return data as UserDto, то фактически скажем приложению: “поверь мне, там всё правильно”. Я предпочитаю не верить, а проверять.
Особенно это важно на границах системы: ответы внешних API, результаты запросов к базе данных, webhook-и, gRPC-ответы, данные из очередей, DTO между важными слоями приложения, конфиги и env-переменные. Для этого удобно использовать Zod:
В результате функция возвращает не “что-то похожее на UserDto”, а либо гарантированно валидные данные, либо понятную ошибку. Обычно я валидирую такие данные на серверной стороне. В некоторых случаях дополнительно проверяю ответ и на клиенте, особенно если данные критичны для интерфейса или бизнес-логики.
Почему важен код ошибки
Мне не нравится, когда ошибка — это просто текст вроде Something went wrong или Invalid data. Такой текст сложно нормально использовать в логах, мониторинге, тестах и клиентской логике. Мне больше нравится, когда у ошибки есть стабильный код: OUTPUT_SCHEMA_ERROR, USER_NOT_FOUND, FORBIDDEN, EXTERNAL_SERVICE_ERROR. Текст можно менять, но код должен оставаться стабильным.
Если я вижу в логах OUTPUT_SCHEMA_ERROR, я сразу понимаю: проблема не в бизнес-логике, а в том, что какой-то слой вернул данные не того формата. Если вижу USER_NOT_FOUND, понятно, что это ожидаемый бизнес-сценарий. Если вижу EXTERNAL_SERVICE_ERROR, понятно, что проблема во внешнем сервисе. Это сильно упрощает отладку и поддержку проекта.
Почему я не люблю зашивать смысл ошибки только в HTTP-статус
Ещё одна вещь, которая мне не нравится в API, — когда смысл ошибки зашит только в HTTP-статусе. Например, приходит 404, и дальше нужно по контексту понимать: что именно не найдено? Пользователь? Заказ? Файл? Endpoint? Сущность есть, но у пользователя нет доступа? Backend прокинул запрос не туда? То же самое с 500: формально это “ошибка сервера”, но для прикладного кода этого почти никогда недостаточно. Мне важно понимать, что именно произошло: внешний сервис не ответил, схема ответа не совпала, не удалось создать запись, нарушено бизнес-правило или возникла неизвестная ошибка.
Поэтому я предпочитаю разделять два уровня: HTTP-статус — это транспорт и инфраструктура, а тело ответа — это результат бизнес-операции. Если сервер нормально принял запрос, обработал его и вернул понятный результат, для меня это 200 OK, даже если сама бизнес-операция завершилась ошибкой:
Для меня это нормальный ответ сервера. Сервер не упал, endpoint существует, запрос дошёл, код отработал. Просто результат операции — неуспешный. А вот если пришёл 500, это уже не часть нормального бизнес-флоу. Это сигнал, что где-то произошла необработанная ошибка, инфраструктурная проблема или ситуация, которую нужно расследовать отдельно.
Throw vs Result
Я не считаю, что throw не нужен вообще. Исключения хорошо подходят для ситуаций, которые действительно являются аварийными: сломалась инфраструктура, произошла необработанная ошибка, нарушилось состояние приложения, код оказался в ветке, где он не должен был оказаться. Но многие ошибки в прикладном коде — ожидаемые: USER_NOT_FOUND, FORBIDDEN, VALIDATION_ERROR, OUTPUT_SCHEMA_ERROR, EXTERNAL_SERVICE_ERROR. Это не обязательно “взрыв” приложения, а нормальные варианты результата операции.
С throw поток выполнения резко прерывается и улетает в ближайший catch. Иногда это удобно, но в прикладной логике такая магия часто мешает. С Result поток остаётся линейным:
Да, это немного более многословно. Зато видно весь сценарий: где получили пользователя, где создали заказ, где обработали возможные ошибки. Ошибка здесь не исключение из потока. Ошибка — это один из возможных типов данных.
А как быть со stack trace?
Один из аргументов против Result — можно потерять stack trace, который обычно даёт throw new Error(). Это справедливое замечание. Я не считаю, что в fault нужно складывать только строку. Внутрь ошибки можно добавить технические детали: оригинальный Error, cause, stack, requestId, ответ внешнего сервиса и другие данные для логирования.
Например:
Главное — не обязательно показывать эти детали пользователю. Но для логов и отладки они могут быть очень полезны.
Что это даёт на клиенте
На клиенте такой подход делает обработку ответа очень простой. Мне не нужно строить логику вокруг набора HTTP-статусов и потом ещё раз пытаться понять, что именно имелось в виду. Я всегда смотрю на тело ответа:
Если isSuccess, значит, в payload лежат данные нужного формата. Если isFault, значит, есть понятная ошибка с кодом. Получается двойной контроль: сервер не отдаёт наружу данные в случайном формате, а клиент не принимает слепо то, что пришло по сети.
Минимальный helper для Result
В базовом варианте такой helper может выглядеть примерно так:
Это не сложная архитектура, а просто общий контракт. Функция возвращает либо объект со статусом success и payload, либо объект со статусом fault, кодом ошибки и сообщением. И этого уже достаточно, чтобы код стал намного предсказуемее.
Это монады?
Формально такой подход похож на Result / Either из функционального программирования. Если добавить методы вроде map, mapError, flatMap или andThen, можно прийти к более монадическому стилю. Но я бы не стал усложнять эту идею там, где в этом нет необходимости.
В обычной прикладной разработке мне чаще всего хватает простого правила: результат операции должен быть явным. Я не пытаюсь превращать TypeScript-код в Haskell. Мне просто нравится идея, что функция не заставляет вызывающий код гадать.
Где я использую такой подход
Я не считаю, что абсолютно каждая функция в проекте должна возвращать Result. Если функция форматирует строку, считает сумму или преобразует дату, ей не обязательно оборачиваться в success(). Но если функция ходит в сеть, работает с базой данных, вызывает внешний сервис, принимает webhook, обрабатывает платёж, работает с правами доступа, возвращает DTO наружу, влияет на деньги, статусы или важные бизнес-сущности, то я предпочитаю явный результат.
В таких местах лучше написать чуть больше кода, но получить предсказуемое поведение. На практике это даёт несколько преимуществ: код проще читать, ошибки становятся частью нормального потока данных, проще писать тесты, проще подключать мониторинг, проще поддерживать проект после роста кодовой базы. А ещё это помогает при работе с AI-generated кодом. Если модуль обязан вернуть Result, а внешний ответ обязан пройти через Zod-схему, то даже сгенерированный код сложнее превратить в хаос. Контракт ограничивает фантазию.
Итог
Мне нравится писать backend так, чтобы вызывающий слой не гадал, что произошло. Функция либо вернула валидные данные, либо вернула понятную ошибку. Данные из внешнего мира проходят runtime-валидацию. Ошибки имеют стабильные коды. HTTP-статус отвечает за транспортный уровень, а результат бизнес-операции лежит внутри ответа.
Для меня это один из признаков хорошего прикладного кода: он не только “работает”, но и предсказуемо сообщает, что именно произошло. Особенно это важно в проектах, которые уже выросли из стадии “быстро набросали MVP” и начали сталкиваться с интеграциями, платежами, внешними API, webhook-ами, очередями, правами доступа и сложной бизнес-логикой.
Если у вас есть проект, где backend уже стал сложнее, чем хотелось бы, или вы хотите заранее заложить более предсказуемую архитектуру, можно обсудить это со мной. Я занимаюсь full-stack разработкой, проектированием backend-логики, интеграциями и аудитом существующего кода. Больше о моём опыте и проектах можно посмотреть на сайте: petrtcoi.com/ru/.