Async Adapters — promisify / callbackify / asyncify

Адаптеры между контрактами асинхронности в JS: callback-last/error-first ↔ Promise ↔ async function ↔ sync. Частный случай Adapter Pattern.

Проблема

В одном приложении сосуществуют три-четыре контракта:

  • sync: const x = fn(a, b)
  • callback-last, error-first: fn(a, b, (err, res) => ...)
  • Promise: fn(a, b).then(res => ...).catch(err => ...)
  • async: try { const x = await fn(a, b) } catch (e) { ... }

Между ними нужно стыковать абстракции.

Решение

Унификация через адаптеры:

  • promisify(fn) — callback-функция → возвращает Promise (для await).
  • callbackify(asyncFn) — async-функция → принимает callback (для legacy API).
  • asyncify(syncFn) — sync-функция → callback (через setTimeout или setImmediate).
  • Sync ← async — невозможно (фундаментальное ограничение).

Пример в JS

// promisify (упрощённая версия из util)
const promisify = (fn) => (...args) =>
  new Promise((resolve, reject) => {
    fn(...args, (err, res) => err ? reject(err) : resolve(res));
  });

const readFile = promisify(require('fs').readFile);
const data = await readFile('config.json', 'utf8');

// callbackify
const callbackify = (asyncFn) => (...args) => {
  const callback = args.pop();
  asyncFn(...args).then((res) => callback(null, res), (err) => callback(err));
};

// asyncify — sync → async через setTimeout
const asyncify = (fn) => (...args) => {
  const callback = args.pop();
  setTimeout(() => {
    try {
      const res = fn(...args);
      callback(null, res);
    } catch (err) {
      callback(err);
    }
  }, 0);
};

Где используется в JS-экосистеме

  • util.promisify (Node.js) — встроенный
  • util.callbackify (Node.js) — встроенный
  • bluebird.promisifyAll — для promisify целой библиотеки
  • fs/promises — уже promisified версии fs
  • обёртки над legacy API: SQLite3, leveldown — promisify нужен

Подводные камни

  • Async → sync нельзя: никаким адаптером Promise не превращается в sync-значение.
  • promisify предполагает контракт callback-last, error-first — если другой порядок, нужен кастомный.
  • setTimeout(fn, 0) в asyncify отдаёт управление в event loop — гарантирует асинхронность.
  • promisify теряет this — для методов нужен .bind(obj).
  • util.promisify[util.promisify.custom] — кастомный promisifier для нестандартных контрактов.

Главные тезисы автора

  • «Все эти контракты в приложениях часто используются — несколько из них одновременно».
  • promisify в Node.js лежит в util, в браузере его нет — нужно писать самому.
  • «Sync → async можно, async → sync нельзя» — фундаментальное ограничение.
  • asyncify через setTimeout разрывает синхронность — отдаёт управление в event loop.
  • Promisify/callbackify — классические адаптеры контрактов асинхронности.
  • Стыковка callback-last, error-first и Promise — основное назначение.
  • Адаптер часто использует другие паттерны внутри (revealing constructor).

🎓 Источники

См. также