Опирается на правила: NODE-20, NODE-21, NODE-X8, NODE-X9 из Node Style Guide → раздел 5. Async.

Важно знать

  • Только async/await — raw .then()/.catch()-цепочки запрещены; исключение — библиотечный API, где callback обязателен по контракту.
  • ПараллелизмPromise.all / Promise.allSettled по месту, не последовательные await.
  • Каждый Promise — awaited, returned или явно обработан: ESLint-правила no-floating-promises и no-misused-promises включены как error.
  • async-функция без await внутри — бессмысленный шум; удалять async или добавлять реальное ожидание.
  • Fire-and-forget (void doStuff()) без catch-канала — unhandled rejection, которая в Node.js валит процесс.
  • await не-Promise (await 42, await undefined) — шум, скрывающий реальные точки ожидания; tsc и ESLint это не поймают без type-checked пресета.
  • no-floating-promises и no-misused-promises входят в strictTypeChecked пресет @typescript-eslint — при правильно настроенном tooling оба правила работают без ручного включения.
  • Деньги в async-обработчиках — bigint (минорные единицы); не допускать number в промежуточных расчётах (NODE-18).

Асинхронный код в Node.js — это основной режим работы, а не исключение. async/await делает его читаемым: поток выполнения следует сверху вниз, try/catch покрывает ошибки так же, как в синхронном коде, а стек трейс сохраняет реальную точку возникновения ошибки. Этот раздел фиксирует, как писать async-код так, чтобы он оставался надёжным и читаемым по мере роста сервиса.

async/await вместо .then()/.catch()

NODE-20: async/await для всех промисов.

async createOrder(command: CreateOrderCommand): Promise<OrderId> {
  const customer = await this.customerRepository.findById(command.customerId);
  if (!customer) {
    throw new CustomerNotFoundError(command.customerId);
  }
  const order = Order.create(customer, command.items);
  await this.orderRepository.save(order);
  return order.id;
}

Raw-цепочки .then()/.catch() превращают линейный поток в callback-ад:

createOrder(command: CreateOrderCommand): Promise<OrderId> {
  return this.customerRepository.findById(command.customerId)
    .then(customer => {
      if (!customer) throw new CustomerNotFoundError(command.customerId);
      return Order.create(customer, command.items);
    })
    .then(order => this.orderRepository.save(order).then(() => order.id));
}

Первый вариант читается как синхронный код, второй — растёт вглубь при каждом новом шаге. Стек трейс в async/await сохраняет точку await, где реально случилась ошибка; в цепочках .then() стек обрезается на промис-механизме.

Параллельные операции

Когда несколько операций независимы, Promise.all по месту:

async enrichOrder(orderId: OrderId): Promise<EnrichedOrder> {
  const [order, customer, products] = await Promise.all([
    this.orderRepository.findById(orderId),
    this.customerRepository.findByOrderId(orderId),
    this.productRepository.findByOrderId(orderId),
  ]);
  return EnrichedOrder.from(order, customer, products);
}

Последовательные await в этом случае — трата времени:

const order = await this.orderRepository.findById(orderId);
const customer = await this.customerRepository.findByOrderId(orderId);
const products = await this.productRepository.findByOrderId(orderId);

Три независимых запроса выполняются друг за другом вместо параллельно. На реальных латентностях это заметно.

Promise.allSettled для fault-tolerant сценариев

Когда часть операций может упасть, а остальные должны выполниться:

async notifyCustomers(customerIds: readonly string[]): Promise<void> {
  const results = await Promise.allSettled(
    customerIds.map(id => this.notificationService.notify(id)),
  );

  const failed = results.filter(r => r.status === 'rejected');
  if (failed.length > 0) {
    this.logger.warn('Some notifications failed', { count: failed.length });
  }
}

Promise.all прерывается при первой ошибке; Promise.allSettled ждёт всех и даёт полную картину.

Каждый Promise должен быть awaited или обработан

NODE-21: floating promise — это необработанная ошибка в ожидании.

ESLint-правила no-floating-promises и no-misused-promises из пресета strictTypeChecked делают это проверяемым:

async processPayment(orderId: OrderId): Promise<void> {
  await this.paymentGateway.charge(orderId);
  await this.orderRepository.markAsPaid(orderId);
}

Если нужно вернуть промис из метода — возвращаем явно:

async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
  return this.createOrderHandler.handle(event.toCommand());
}

Одиночный return await внутри try/catch — единственный случай, где await перед return имеет смысл: без него исключение из промиса уйдёт мимо catch:

async withRetry<T>(operation: () => Promise<T>): Promise<T> {
  try {
    return await operation();
  } catch (error) {
    this.logger.error('Operation failed, retrying', { error });
    return await operation();
  }
}

Запрет fire-and-forget

NODE-X9: fire-and-forget без catch-канала валит процесс.

async processOrder(command: ProcessOrderCommand): Promise<void> {
  await this.orderRepository.save(command.order);
  void this.auditLogger.log(command);
}

Если this.auditLogger.log завершится с ошибкой, Node.js получит unhandled rejection. В зависимости от версии и конфигурации процесс либо падает, либо глотает ошибку молча.

Если операция действительно должна быть фоновой — нужен явный catch-канал:

async processOrder(command: ProcessOrderCommand): Promise<void> {
  await this.orderRepository.save(command.order);
  this.auditLogger.log(command).catch(error => {
    this.logger.error('Audit log failed', { error });
  });
}

Лучше — вынести в отдельный метод с явным именем:

private scheduleAuditLog(command: ProcessOrderCommand): void {
  this.auditLogger.log(command).catch(error => {
    this.logger.error('Audit log failed', { error });
  });
}

Имя scheduleAuditLog сигналит читателю: это намеренно fire-and-forget, а не пропущенный await.

async без await — бессмысленный шум

NODE-X8: async-функция без await — либо ошибка, либо лишний модификатор.

async getOrderCount(): Promise<number> {
  return this.orders.length;
}

Функция не ожидает ничего асинхронного. ESLint-правило @typescript-eslint/require-await поймает это. Если функция должна соответствовать асинхронному интерфейсу — это честно указать в типе явно, без async:

getOrderCount(): Promise<number> {
  return Promise.resolve(this.orders.length);
}

Или убрать Promise из возвращаемого типа, если асинхронность не нужна:

getOrderCount(): number {
  return this.orders.length;
}

Симметричный антипаттерн — await не-Promise:

async createProduct(name: string): Promise<Product> {
  const id = await generateId();
  const label = await name.trim();
  return { id, name: label };
}

name.trim() возвращает string, не Promise. await не вредит функционально, но скрывает, где реально происходит ожидание — читатель видит два await и не понимает, что ждём только одно.

NestJS: async в хендлерах и контроллерах

В NestJS весь слой обработки UseCase строится на async:

@Injectable()
export class CreateOrderHandler {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly customerRepository: CustomerRepository,
  ) {}

  async handle(command: CreateOrderCommand): Promise<OrderId> {
    const customer = await this.customerRepository.findById(command.customerId);
    if (!customer) {
      throw new CustomerNotFoundError(command.customerId);
    }
    const order = Order.create(customer, command.items);
    await this.orderRepository.save(order);
    return order.id;
  }
}

Контроллер делегирует хендлеру и не содержит бизнес-логики:

@Controller('orders')
export class OrderController {
  constructor(private readonly createOrderHandler: CreateOrderHandler) {}

  @Post()
  async createOrder(@Body() dto: CreateOrderDto): Promise<{ id: string }> {
    const id = await this.createOrderHandler.handle(dto.toCommand());
    return { id: id.value };
  }
}

Ошибки из хендлера поднимаются до NestJS ExceptionFiltertry/catch в контроллере не нужен, если только не нужна особая трансформация ошибки в HTTP-ответ.

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

АнтипаттернПравилоЧто взамен
.then(a => ...).catch(e => ...) вместо async/awaitNODE-20async/await + try/catch
Последовательные await для независимых операцийNODE-20Promise.all([...])
void this.service.doStuff() без catch-каналаNODE-X9.catch(error => this.logger.error(...))
async fn() { return value; } без await внутриNODE-X8убрать async или вернуть Promise.resolve(value)
await someString / await 42NODE-X8убрать лишний await
Promise без await, return и без .catch()NODE-21всегда await или return или .catch()
Promise.all без обработки частичных ошибокNODE-20Promise.allSettled если нужна fault-tolerance

Куда дальше

  • node/naming.md — именование async-методов: глагол, find* vs get*, handle* для event handlers (NODE-8).
  • node/imports.md — named exports и path-aliases: структура, от которой зависит DI в async-хендлерах.
  • node/expressions.md — unknown + narrowing, guard clause, явные возвращаемые типы.
  • node/immutability.md — readonly на полях, as const, spread вместо мутации аргументов.
  • node/tooling.md — tsconfig.json strict, ESLint strictTypeChecked, CI-прогон.
  • Раздел «Стандарты → Error Handling → Node» — как оформить catch-блоки, кастомные ошибки и фильтры NestJS.
  • Раздел «Стандарты → Code Style» — хаб языковых биндингов: Java, Node, Python, Go.