Опирается на правила:
R-ERR-RESULT-1…R-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 Handler | R-ERR-RESULT-2 | Promise<T>, исключение летит до фильтра |
Result<T, E> в Repository / Port методах | R-ERR-RESULT-2 | Promise<T>, адаптер бросает IntegrationError |
.isErr() на каждом уровне call stack | R-ERR-RESULT-X1 | Исключения — автоматическое propagation |
neverthrow без реального pattern-matching | R-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.