Привет, Хабр!

Не раз ловил себя на том, как в код‑ревью всплывает одна и та же проблема: часть наших функций синхронные, часть асинхронные, а часть ведут себя как шрёдингеровские коты и делают вид, что синхронны, пока не дотронешься. В итоге в одном месте у нас try/catch, в другом.catch, где‑то внезапно падает исключение, а в соседнем модуле молча утекает Promise. С появлением нативного Promise.try стало проще навести порядок и избавиться от разнобоя. Фича прошла процесс стандартизации в TC39 и включена в спецификацию ECMAScript 2026, при этом уже с января доступна в актуальных движках. Можно перестать спорить про обёртку из Promise.resolve().then и получить единый вход для sync/async с нормальной обработкой ошибок.

Зачем вообще Promise.try

Promise.try синхронно вызывает callback, заворачивает возвращаемое значение или thenable в Promise и превращает синхронный throw в rejected Promise. В отличие от привычного трюка с Promise.resolve().then, здесь нет лишней асинхронной прокладки. Спека это фиксирует отдельно: функция вызывается синхронно, а дальше уже работают обычные правила промисов.

Базовый интерфейс:

Promise.try(fn, arg1, arg2 /* ... */) // возвращает Promise

Можно передавать аргументы вторым и далее параметрами, чтобы не плодить лишние лямбды.

Проблема смешанного API

Частая ситуация: у вас есть plug‑in API или доменный слой, где часть реализаций синхронные (например, берут данные из памяти), а часть асинхронные (идут в сеть или диск). Вызывающий код при этом хочет одинаковое поведение. Без Promise.try получаются такие конструкции:

function run(handler, payload) {
  try {
    const maybe = handler(payload);
    return Promise.resolve(maybe).catch(err => {
      // логика единая, но синхронный throw мы ловим только этим try/catch
      report(err);
      throw err;
    });
  } catch (e) {
    report(e);
    return Promise.reject(e);
  }
}

Живёт, но выглядит как‑то не очень. С Promise.try это становится нормальным кодом без двойного ветвления.

Базовый шаблон с Promise.try

function run(handler, payload) {
  return Promise
    .try(handler, payload)
    .catch(err => {
      report(err);
      throw err;
    });
}

Решаем сразу три задачи. Первое: синхронные исключения превращаются в rejected Promise. Второе: асинхронный handler возвращает Promise как есть. Третье: семантика старта синхронная, то есть handler дернётся в том же тике, без.then и микротаска в середине.

По сравнению с Promise.resolve().then(handler) разница действительно есть. then всегда вызывает callback асинхронно, Promise.try делает вызов сразу.

И еще. Многие когда‑то решали задачу через new Promise(res => res(handler())). Это работает, но шаблон громоздкий и логически запутывает. TC39-предложение как раз ровно эту эргономику стандартизовало, путь от библиотеки Bluebird/Q до языка занял годы. Сейчас уже не нужно тащить внешние зависимости просто ради стартера цепочки.

TypeScript: делаем единый вход MaybePromise

Часто интерфейс видит T | Promise. Зафиксируем тип и утилиту, чтобы не плодить.then в слое адаптеров.

// types.ts
export type MaybePromise<T> = T | Promise<T>;

export function toPromise<T>(thunk: () => MaybePromise<T>): Promise<T> {
  // нативный Promise.try, доступен в современных рантаймах
  return Promise.try(thunk);
}

С этой крошечной утилитой адаптировать шатающийся API просто:

// service.ts
import { toPromise } from './types';

export function computeSync(x: number): number {
  if (x < 0) throw new RangeError('x must be >= 0');
  return x * 2;
}

export async function computeAsync(x: number): Promise<number> {
  await waitIO();
  if (x === 13) throw new Error('unlucky');
  return x * 3;
}

// контроллеру всё равно, sync это или async
export async function handle(x: number): Promise<number> {
  return toPromise(() => (x % 2 === 0 ? computeSync(x) : computeAsync(x)));
}

Если computeSync бросит исключение, мы поймаем его как reject. Если computeAsync вернёт отклонённый Promise, получим тот же reject.

Result-паттерн поверх Promise.try

Ошибки удобно сводить к явной сумме вариантов, чтобы не размазывать try/catch по коду. Нужен утилитарный toResult:

// result.ts
export type Ok<T> = { ok: true; value: T };
export type Err<E = unknown> = { ok: false; error: E };
export type Result<T, E = unknown> = Ok<T> | Err<E>;

export async function toResult<T, E = unknown>(
  thunk: () => T | Promise<T>
): Promise<Result<T, E>> {
  return Promise
    .try(thunk)
    .then<Ok<T>>(value => ({ ok: true, value }))
    .catch<Err<E>>(error => ({ ok: false, error as E }));
}

Пример в обработчике:

const res = await toResult(() => doWork(input));
if (!res.ok) {
  // централизованный маппинг ошибок
  log(res.error);
  throw normalize(res.error); // или верните HTTP 400/500 в вебе
}
return res.value;

Смысл в том, что бизнес‑код остаётся линейным, а Promise.try гарантирует, что к нам всегда придёт либо Ok, либо Err в одном формате.

Ошибки, которые не Error

JavaScript разрешает отклонять промисы чем угодно: строкой, числом, чем попало. Лучше на уровне границ приложения нормализовать ошибки к экземплярам Error. Вокруг Promise.try это делается в одном месте:

function ensureError(x: unknown): Error {
  if (x instanceof Error) return x;
  try {
    return new Error(typeof x === 'string' ? x : JSON.stringify(x));
  } catch {
    return new Error('Non-serializable rejection');
  }
}

export async function toResultStrict<T>(
  thunk: () => T | Promise<T>
) {
  return Promise
    .try(thunk)
    .then(value => ({ ok: true as const, value }))
    .catch((e) => ({ ok: false as const, error: ensureError(e) }));
}

Node.js: unhandledRejection и глобальные перехваты

Даже с Promise.try промисы могут остаться без обработчиков, если кто‑то забыл await или.catch. В Node есть событие process.on('unhandledRejection'), которое помогает обнаружить такие места.

Минимальная заготовка для Node:

import process from 'node:process';

process.on('unhandledRejection', (reason, promise) => {
  // логируем нормализованно
  const err = reason instanceof Error ? reason : new Error(String(reason));
  console.error('UNHANDLED_REJECTION', { message: err.message, stack: err.stack });

  // дальше по политике команды: падать, метить инцидент, мять трафик
  // не дергайте process.exit() вслепую — можно потерять логи
});

Отличия от async-обёртки и от then

Часто можно встретить утверждение, что достаточно сделать async () => fn(), и синхронный throw станет reject. Это правда, но есть два нюанса.

Первый. Чтобы превратить вызов fn в Promise, вам всё равно надо либо вызвать его внутри async‑функции, либо сделать Promise.resolve().then(fn). В обоих случаях callback по факту уходит в микротаску. Promise.try вызывает fn сразу.

Второй. Если важно максимально зеркалить поведение самого async‑function до первого await (то есть синхронный старт, а дальше уже по стандартным правилам промисов), Promise.try даёт ту же семантику, но без создания лишней обёртки и без необходимости вспоминать как же там правильно писать new Promise(resolve => resolve(fn())).

Отличие от Promise.resolve().then уже проговорили: then всегда планирует вызов позже. Это может повлиять на порядок логов, трассировку и гонки с отменой. Если вам без разницы, то с точки зрения корректности оба варианта ок. Если порядок важен, берите Promise.try.

Совместимость и полифиллы

На актуальных движках промисы с try уже доступны. Если у вас целевая матрица включает старые браузеры или ранние Node, есть три очевидных пути:

  1. core‑js, начиная с 3.37, содержит полифилл Promise.try. Пример есть прямо в README пакета.

  2. es‑shims/promise.try — лёгкий шим, если не хотите тянуть весь core‑js.

  3. p‑try — минималистичная ponyfill‑функция, которую можно использовать точечно и постепенно убрать после обновления рантайма.

Если вы живёте в историческом коде с Bluebird или Q, у них Promise.try и Q.fcall делали ровно это много лет. Сейчас есть смысл либо перейти на нативный API, либо оставить тонкую прослойку совместимости и постепенно вычищать зависимости.

Миграция с Bluebird/Q

Если у вас исторический слой с Bluebird Promise.try или Q.fcall, переход несложный:

  • В местах, где возвращали Bluebird‑промисы из Promise.try, начинайте возвращать нативный Promise.try.

  • Если Bluebird.config включал длинные стэктрейсы и catch‑фильтры, сразу не пропадёт, потому что это плюшки конкретной библиотеки, а не стандарта.

  • На Q.fcall замену делайте через нативный Promise.try с передачей аргументов: Promise.try(fn,...args).

Если вам нужна поэтапная миграция, p‑try можно использовать как временную прослойку в тех пакетах, где ещё нет полифилла.


Итоги

Promise.try — небольшое дополнение к языку, которое закрывает много проблем. Единый вход для синхронных и асинхронных функций, единая ошибка в catch, предсказуемый порядок выполнения без лишних микрозадач. В прошлом это решали Bluebird/Q и p‑try. Теперь это часть стандарта, с нормальной документацией, полифиллами и поддержкой в движках. Если вы до сих пор стартуете цепочки через resolve().then или new Promise ради обёртки, самое время завести утилиту на Promise.try и пройтись по слоям адаптеров.

Многие разработчики рано или поздно сталкиваются с задачей унификации работы с синхронными и асинхронными функциями. Promise.try стал простым и понятным инструментом, который избавляет от разнобоя и позволяет сосредоточиться на логике, а не на различиях в обработке ошибок. Но подобные тонкости — лишь часть того, что важно знать современному разработчику, особенно если речь идёт о полноценной работе и с серверной, и с клиентской частью.

Если вам близка эта тема, приглашаем вас на специализацию "Fullstack developer", где с нудя детально разбираются фундаментальные и прикладные вопросы разработки. Также вы можете ознакомиться с другими программами в каталоге курсов и подобрать подходящее направление.

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

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


  1. Sirion
    27.08.2025 17:35

    Чем только люди не занимаются, лишь бы не использовать async/await


    1. nihil-pro
      27.08.2025 17:35

      async function doWork1() {
        return new Promise(resolve => {
          setTimeout(resolve, 3000)
        })
      }
      
      async function doWork2() {
        return new Promise(resolve => {
          setTimeout(resolve, 3000)
        })
      }
      
      async function main() {
        await doWork1()
        await doWork2()
      }
      
      const t1 = performance.now()
      await main()
      const t2 = performance.now();
      console.log('script took', t2-t1, 'ms')
      script took 6002.199999988079 ms

      Против:

      function doWork1() {
        return new Promise(resolve => {
            setTimeout(resolve, 3000)
        })
      }
      
      function doWork2() {
        return new Promise(resolve => {
            setTimeout(resolve, 3000)
        })
      }
      
      function main() {
        return Promise.all([doWork1(), doWork2()])
      }
      
      const t1 = performance.now()
      main().then(() => {
        const t2 = performance.now();
        console.log('script took', t2-t1, 'ms')
      })
      script took 3002 ms

      Чем только люди не занимаются, лишь бы не использовать async/await

      Думают, наверное.

      async/await превращает асинхронный код – в сихронный.


      1. Sirion
        27.08.2025 17:35

        Я не уследил, в какой момент в РФ запретили await Promise.all


  1. rock
    27.08.2025 17:35

    es‑shims/promise.try — лёгкий шим, если не хотите тянуть весь core‑js.

    Вы удивитесь, сколько мусора promise.try тянет -)