new Promise(resolve => executor(resolve)) опаснее, чем кажется, и как типизировать ручное управление resolve/rejectНа code review я часто вижу этот паттерн от middle и senior разработчиков. В production — в Node.js сервисах, подписках на EventEmitter, SPA с кастомными колбэками — это фабрика висячих промисов, которые никогда не завершаются. Основная ошибка: передача
resolve как колбэка без обёртки.Почему промис «не зарезолвится»?
Когда вы пишете
new Promise(resolve => executor(resolve)), внутренний executor сохраняет ссылку на resolve как колбэк. Если executor — подписка на EventEmitter или долгоживущий объект, resolve живёт в замыкании, пока жив объект. Промис ждёт вызова, которого не будет — утечка памяти гарантирована. GC не соберёт его из-за замкнутой ссылки.Пример:
function subscribeToEvents(emitter) {
return new Promise(resolve => {
emitter.on('data', resolve); // resolve висит, пока emitter жив
// emitter никогда не эмитит — промис висит вечно
});
}Типизировать resolve/reject: не просто
(value?) => voidПростая сигнатура не показывает, что resolve должен вызываться ровно один раз. Правильнее — замыкание с внутренним состоянием или явный Deferred.
Лучшее решение —
Promise.withResolvers() (ES2024) или кастомный Deferred:interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: Deferred<T>['resolve'];
let reject!: Deferred<T>['reject'];
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}Deferred даёт явное управление жизнью промиса — вы контролируете, когда и как его завершить. Это trade-off: больше кода, но меньше утечек.
Как избежать висячих промисов
Совет: если сохраняете resolve в объекте (подписка на класс), удаляйте ссылку после завершения через
.finally(). Иначе накопите кучу функций, которые GC не вычистит.const deferred = createDeferred<Data>();
emitter.on('data', deferred.resolve);
deferred.promise.finally(() => {
emitter.off('data', deferred.resolve);
});
Предупреждение: передавать resolve как колбэк в сторонние подписки без привязки к жизненному циклу — плохая идея. Используйте Deferred с гарантией однократного вызова и очисткой. Утечки через промисы трудно ловить, но код должен быть чистым по памяти.
Вывод: Ручное управление resolve/reject в JavaScript требует явного контроля жизненного цикла — без Deferred или
Promise.withResolvers() вы рискуете утечками памяти, которые не видны в тестах, но проявляются в production при долгоживущих подписках.