ESC

Type to search the knowledge base.

Promisify a Node-Style Callback

Turn (err, value) callbacks into promises. Multiple success args, functions that already return thenables, and why you still need the original this.

intermediate6 min read
  • javascript
  • promises
  • callbacks
  • interview

A Node-style function takes a callback last: (err, value) => {}. You promisify it so callers can await. The usual miss is treating the wrapper like Promise.resolve(fn()). That never passes a callback, so the work never finishes, or it finishes twice.

The other miss is dropping this. fs.readFile does not care. obj.load(cb) does. If the wrapper is an arrow, or you call fn(...args, cb) without .call, methods read the wrong object and you debug a undefined that was never in the original API.

Node’s util.promisify is the mental model. You do not need to import it in the interview. You do need its rules: truthy err rejects, extra success values are not a second resolve, and a function that already returns a thenable is a different problem.

Key takeaways

  • Last argument is (err, value). Truthy err rejects. Otherwise resolve the value.
  • Default Node behavior keeps only the first success argument. Pack the rest only if you say you will.
  • The wrapper is a plain function so this comes from the call site; forward it with fn.call(this, ...).
  • If fn already returns a thenable and ignores the extra callback, a naive wrap hangs. Do not double-adopt.

Reject on truthy err, resolve on the rest

Spec for promisify(fn):

  1. If fn is not a function, throw TypeError.
  2. Return a function that, when called, returns a new promise.
  3. Invoke fn with the same this, the given arguments, and one extra callback.
  4. On callback(err, ...values): if err is truthy, reject with err; else resolve.
  5. A throw from fn itself rejects. A second callback is a no-op.
function promisify(fn) {
  if (typeof fn !== 'function') {
    throw new TypeError('promisify: fn must be a function');
  }

  return function promisified(...args) {
    return new Promise((resolve, reject) => {
      let settled = false;

      function done(err, value) {
        if (settled) return;
        settled = true;
        if (err) reject(err);
        else resolve(value);
      }

      try {
        fn.call(this, ...args, done);
      } catch (err) {
        done(err);
      }
    });
  };
}
function readUser(id, cb) {
  if (!id) {
    cb(new Error('missing id'));
    return;
  }
  cb(null, { id, name: 'Ada' });
}

const readUserP = promisify(readUser);
await readUserP(7); // { id: 7, name: 'Ada' }

if (err) is the Node check, not err != null. callback(null, v) and callback(undefined, v) fulfill. callback(0, v) also fulfills, because 0 is falsy. callback(false, v) fulfills. That is the convention, and it is how you should talk about truthy and falsy values if the interviewer pushes.

resolve and reject already ignore a second call. The settled flag still matters: a sync throw after a sync cb(null, v) would otherwise reject a promise that already queued a fulfillment. First completion wins.

Multiple success arguments

Some APIs do cb(null, bytesRead, buffer). Default util.promisify resolves only the first success argument. The buffer disappears.

Callback Default resolve If you pack extras
cb(null, a) a a
cb(null, a, b) a [a, b]
cb(null) undefined undefined
cb(err) reject err reject err
function promisify(fn, { multiArgs = false } = {}) {
  if (typeof fn !== 'function') {
    throw new TypeError('promisify: fn must be a function');
  }

  return function promisified(...args) {
    return new Promise((resolve, reject) => {
      let settled = false;

      function done(err, ...values) {
        if (settled) return;
        settled = true;
        if (err) {
          reject(err);
          return;
        }
        if (multiArgs) resolve(values);
        else resolve(values[0]);
      }

      try {
        fn.call(this, ...args, done);
      } catch (err) {
        done(err);
      }
    });
  };
}

Say the default out loud: first value, rest dropped. Offer multiArgs or a named object if the prompt is fs.read. Node’s real escape hatch is fn[util.promisify.custom], a function you return instead of wrapping. In a whiteboard session, a comment that “custom symbol replaces the wrap” is enough unless they ask you to implement the symbol lookup.

this, thenables, and functions that already return promises

Preserve call-site this. The wrapper must be a function, not an arrow, and the inner call is fn.call(this, ...):

const store = {
  prefix: 'user:',
  get(id, cb) {
    cb(null, this.prefix + id);
  },
};

const getP = promisify(store.get);
await getP.call(store, 3); // 'user:3'
store.getP = promisify(store.get);
await store.getP(3); // method call sets this

this binding and call / apply / bind are the same rules you already know. promisify(store.get).bind(store) is valid if the caller will not pass this later.

Thenables are the other trap. promisify exists for callback-last functions. If fn already returns a promise (or any thenable) and ignores the extra argument, done never runs and the wrapper hangs.

async function alreadyAsync(id) {
  return { id };
}

const broken = promisify(alreadyAsync);
// broken(1) pending forever: alreadyAsync never calls the extra cb

If the interview wants a hybrid, adopt a thenable return and ignore the callback path:

const ret = fn.call(this, ...args, done);
if (ret != null && typeof ret.then === 'function') {
  Promise.resolve(ret).then(
    (value) => done(null, value),
    (err) => done(err),
  );
}

Promise.resolve unwraps thenables the way the spec does. Do not call ret.then yourself without the Promises adopt rules (thenable cycles, non-callable then). Prefer not to promisify a function that is already async. Use it.

A dual-style function (callback optional, promise if omitted) is why Node grew promisify.custom. Detecting both a return value and a later callback can settle twice; keep the settled flag.

Footguns

  • if (err !== null) rejects on callback(undefined) in some code and accepts callback(0). Match truthy, and say so.
  • Arrow wrapper. this is lexical, methods break.
  • Spreading into resolve(a, b). resolve takes one value. Use an array or pick a.
  • No try/catch around fn.call. Sync throws must reject, not become an uncaught exception.
  • Assuming order of then vs a sync callback. If fn calls cb before returning, reactions still run as microtasks. That is normal. See the event loop.

Interview answer (out loud)

“I wrap a Node-style function by returning a new function that builds a Promise and calls the original with the same this plus a callback. Truthy err rejects, otherwise I resolve the first success value, which matches util.promisify. If the prompt has multiple success arguments I either pack an array behind a flag or call out the custom-symbol escape hatch. I guard so a second callback or a throw after a sync cb cannot settle twice. If the function already returns a thenable, I do not attach a callback I will never hear; I use the thenable or Promise.resolve.”

Further reading

Related guides