Опирается на правила: R-ERR-RESULT-1R-ERR-RESULT-2 и R-ERR-RESULT-X1 из Error Handling Style Guide → раздел 6. Result-types vs exceptions.

Важно знать

  • neverthrow.Result / fp-ts Either — допустим точечно в чисто-функциональных модулях (парсер, calc engine, конвертер формата).
  • В цепочке UseCase Handler → Domain → Adapter — исключения, не Result.
  • Глобальная замена исключений на Result превращает каждый вызов в .isErr()-разбор — ломает читаемость без выигрыша.
  • TypeScript без полноценного pattern-matching (нет match на уровне языка) делает Result многословным.
  • Exception Filters в NestJS уже дают «type-safe error handling» на уровне транспорта — без Result.
  • Если возвращаемое значение T | null — это про «не нашли», не про ошибку. Result здесь лишний.

Аргумент за Result<T, E> обычно звучит как «принудительная обработка ошибок на уровне типов». Это звучит убедительно, но в NestJS-стеке с иерархией типов и per-type Exception Filters этот аргумент слабеет: тип уже зафиксирован в иерархии (DomainError, IntegrationError), обработка уже принудительна через фильтры. Добавлять Result сверху — двойной механизм без дополнительной безопасности. Раскрытие правил R-ERR-RESULT-* ниже.

Где Result уместен

R-ERR-RESULT-1: точечно в чисто-функциональных модулях, где ошибка семантически часть возвращаемого значения.

Парсер сложного формата

// core/parsers/bank-statement.parser.ts
import { Result, ok, err } from 'neverthrow';

export interface ParseError {
  line: number;
  reason: string;
}

export function parseBankStatement(
  raw: string,
): Result<BankStatement[], ParseError[]> {
  const lines = raw.split('\n').filter(Boolean);
  const errors: ParseError[] = [];
  const entries: BankStatement[] = [];

  for (let i = 0; i < lines.length; i++) {
    const parsed = parseStatementLine(lines[i]);
    if (parsed === null) {
      errors.push({ line: i + 1, reason: `Invalid format: "${lines[i]}"` });
    } else {
      entries.push(parsed);
    }
  }

  return errors.length > 0 ? err(errors) : ok(entries);
}

Почему Result здесь уместен:

  • Ошибка семантически — часть результата. Парсер возвращает либо «успешно разобрано», либо «список строк с ошибками». Это не «упало» — это нормальный итог работы парсера.
  • Список ошибок нужен целиком. Исключение останавливает на первой ошибке. Result позволяет собрать все ошибки разбора.
  • Модуль чисто-функциональный. Нет IO, нет DI, нет побочных эффектов. Result легко тестировать.
// использование в handler — единственный .isErr() на границе
async handle(cmd: ImportStatementCommand): Promise<void> {
  const result = parseBankStatement(cmd.rawContent);
  if (result.isErr()) {
    throw new InvalidStatementFormatError(cmd.fileId, result.error);
  }
  await this.statements.saveAll(result.value);
}

Результат парсера конвертируется в исключение на границе «функциональный модуль → handler». Handler сам исключений не собирает — бросает InvalidStatementFormatError и дальше по обычной схеме.

Calc engine / конвертер валют

// core/pricing/price-calculator.ts
import { Result, ok, err } from 'neverthrow';

export interface CalculationError {
  code: 'OVERFLOW' | 'UNSUPPORTED_CURRENCY' | 'NEGATIVE_AMOUNT';
  detail: string;
}

export function calculateFinalPrice(
  basePrice: bigint,
  discountPercent: number,
  currency: string,
): Result<bigint, CalculationError> {
  if (!SUPPORTED_CURRENCIES.has(currency)) {
    return err({ code: 'UNSUPPORTED_CURRENCY', detail: `Currency ${currency} not supported` });
  }
  if (basePrice < 0n) {
    return err({ code: 'NEGATIVE_AMOUNT', detail: 'Base price cannot be negative' });
  }
  const discount = (basePrice * BigInt(Math.round(discountPercent * 100))) / 10000n;
  const result = basePrice - discount;
  return ok(result);
}

Чисто-функциональная трансформация без IO. Все возможные ошибки — часть вычислительной семантики. Тест — чистая функция, никакого DI.

Где Result превращается в бойлерплейт

R-ERR-RESULT-2: в цепочке UseCase Handler → Domain → Adapter — исключения, не Result.

Как выглядит глобальный Result в handler

// ПЛОХО — Result везде в handler
async handle(cmd: CreateOrderCommand): Promise<Result<Order, AppError>> {
  const productResult = await this.catalog.getProduct(cmd.productId);
  if (productResult.isErr()) return err(productResult.error);

  const stockResult = checkStock(productResult.value, cmd.quantity);
  if (stockResult.isErr()) return err(stockResult.error);

  const orderResult = Order.create(cmd, productResult.value);
  if (orderResult.isErr()) return err(orderResult.error);

  const saveResult = await this.orders.save(orderResult.value);
  if (saveResult.isErr()) return err(saveResult.error);

  const chargeResult = await this.payment.charge(buildChargeCmd(orderResult.value));
  if (chargeResult.isErr()) return err(chargeResult.error);

  return ok(orderResult.value);
}

Что не так:

  • Каждый вызов — if (result.isErr()) return err(...). Это ручной error-propagation — то, что исключения делают автоматически.
  • Controller разбирает Result снова. if (result.isErr()) { res.status(422)... } — дублирует логику фильтров.
  • Читаемость потеряна. Happy path виден только если «просмотреть сквозь» вертикальные isErr.
  • Compile-time типы не помогают. Result<Order, AppError>AppError слишком широкий, надо уточнять DomainError | IntegrationError | TechnicalError — но тогда возвращаемый тип handler'а меняется с каждым новым исключением.

То же — на исключениях

// PREFER
async handle(cmd: CreateOrderCommand): Promise<Order> {
  const product = await this.catalog.getProduct(cmd.productId);
  if (product.stock < cmd.quantity) {
    throw new ProductOutOfStockError(cmd.productId, cmd.quantity, product.stock);
  }
  const order = Order.create(cmd, product);
  await this.orders.save(order);
  await this.payment.charge(buildChargeCmd(order));
  return order;
}

Happy path линейный. Каждое исключение долетает до нужного фильтра автоматически.

Граница применения: где Result переходит в исключение

При использовании Result в функциональных модулях — конвертируем на границе с handler'ом:

// core/parsers/iban.parser.ts
export function parseIban(raw: string): Result<Iban, string> {
  if (!IBAN_REGEX.test(raw.replace(/\s/g, ''))) {
    return err(`Invalid IBAN format: "${raw}"`);
  }
  return ok(new Iban(raw.replace(/\s/g, '')));
}

// use-cases/register-customer/register-customer.handler.ts
async handle(cmd: RegisterCustomerCommand): Promise<Customer> {
  const ibanResult = parseIban(cmd.bankAccount);
  if (ibanResult.isErr()) {
    throw new InvalidBankAccountError(cmd.customerId, ibanResult.error);
  }
  const customer = Customer.register(cmd, ibanResult.value);
  await this.customers.save(customer);
  return customer;
}

Граница: функциональный парсер → handler → исключение → фильтр. Одно место конвертации, не .isErr() на каждом уровне.

Что запрещено

АнтипаттернПравилоЧто взамен
Result<T, E> как возвращаемый тип UseCase HandlerR-ERR-RESULT-2Promise<T>, исключение летит до фильтра
Result<T, E> в Repository / Port методахR-ERR-RESULT-2Promise<T>, адаптер бросает IntegrationError
.isErr() на каждом уровне call stackR-ERR-RESULT-X1Исключения — автоматическое propagation
neverthrow без реального pattern-matchingR-ERR-RESULT-X1Использовать только там, где ошибка = часть семантики результата
fp-ts Either в handler/service без FP-командыR-ERR-RESULT-X1Исключения; fp-ts — только если команда уже на FP-стеке

Почему «global Result» — не type-safe, а иллюзия

// Кажется type-safe:
async getOrder(id: string): Promise<Result<Order, OrderNotFoundError | DatabaseError>> {
  // ...
}

На практике:

  • Каждый caller обязан разобрать Result — иначе typescript разрешает игнорировать isErr() возвращаемое значение (Promise разворачивается, Result — нет).
  • Caller нарушает правило — const order = (await this.orders.getOrder(id)).value — и order может быть undefined без ошибки компиляции.
  • Исключение в этом сценарии строже: если метод может бросить — typescript не заставит обработать (checked-exceptions нет), но при отсутствии try/catch исключение гарантированно долетит до фильтра. С Result — может тихо проглотиться через .value.

Реальная type-safety — в иерархии типов: DomainError, IntegrationError — конкретные типы, фильтры их различают. Это и есть typed error handling на NestJS-стеке.

Куда дальше

  • Иерархия исключений — типизированная иерархия как альтернатива Result.
  • Где throw, где catch — как исключения автоматически propagate до фильтра.
  • Mapping в ProblemDetails — как фильтры обрабатывают типы.
  • Retry-семантика — retry по типу исключения.
  • Логирование исключений — уровни лога в фильтрах.
  • Observability — метрики и трейсинг.
  • Validation Style Guide → R-VLD-* — про class-validator + InputValidationError.