Привет, Хабр!
Не раз ловил себя на том, как в код‑ревью всплывает одна и та же проблема: часть наших функций синхронные, часть асинхронные, а часть ведут себя как шрёдингеровские коты и делают вид, что синхронны, пока не дотронешься. В итоге в одном месте у нас 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, есть три очевидных пути:
core‑js, начиная с 3.37, содержит полифилл Promise.try. Пример есть прямо в README пакета.
es‑shims/promise.try — лёгкий шим, если не хотите тянуть весь core‑js.
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", где с нудя детально разбираются фундаментальные и прикладные вопросы разработки. Также вы можете ознакомиться с другими программами в каталоге курсов и подобрать подходящее направление.
Кроме того, приходите на бесплатные открытые уроки: в календаре доступны занятия по самым актуальным темам.
Комментарии (13)
PaulIsh
27.08.2025 17:35Чего из предложений js действительно не хватает, так это принятие Temporal всеми браузерами и нодой. Как в базу пойдешь за датой рождения, так драйвер тебе дату-время дает то со смещением часового пояса, то без.
Sirion
Чем только люди не занимаются, лишь бы не использовать async/await
nihil-pro
Против:
Думают, наверное.
async/await
превращает асинхронный код – в сихронный.Sirion
Я не уследил, в какой момент в РФ запретили await Promise.all
voraa
Зачем в первом случае doWork1 и doWwork2 описаны как async?
nihil-pro
Чтобы эмулировать реальное поведение программы с использованием async/await как предлагает @Sirion.
Promise.all
никак это проблему не решает, если только мы не найдем способ собрать в одном месте все функции из разных модулей которые стартуют при старте программы, и все их положить вPromise.all
.В реальности это скорее невозможно, а без async/await все достаточно просто:
Sirion
Крутая возможность async/await - не использовать await, если не нужно дожидаться результата. Просто вызываем что нужно без await, и будет счастье
await Promise.all требуется, если мы хотим получить результаты или хотя бы дождаться момента завершения. Если мы запускаем асинхронные таски в стиле fire&forget, то нам просто не нужен await на верхнем уровне (хотя мы можем использовать async/await в самих тасках)
Sirion
Чтоб не быть голословным, вот эквивалентный код
nihil-pro
Ну и в чем смысл этих
await
-ов, если в итогде вы пришли к моему первому примеру, только менее безопасно?Sirion
Во-первых, поправка: не менее, а более безопасно) в случае чего ошибки вывалятся нормально, а не uncaught (in promise)
Во-вторых, даже просто глазу приятнее. Нет бойлерплейта с .then и стрелками.
В-третьих, а какие именно чудеса вам должен явить async/await? Это по сути синтаксический сахар, позволяющий вместо цепочек .then и .catch (которые могут быть очень длинными, да ещё и вложенными) писать синхронно выглядящий код. Мой пойнт не в том, что с их помощью можно сделать что-то особенное, а в том, что с ними можно сделать то же, что и без них, но красивее, удобнее и ошибкобезопаснее
nihil-pro
Вы просто создаете floating promises.