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.
- 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). Truthyerrrejects. 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
thiscomes from the call site; forward it withfn.call(this, ...).- If
fnalready 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):
- If
fnis not a function, throwTypeError. - Return a function that, when called, returns a new promise.
- Invoke
fnwith the samethis, the given arguments, and one extra callback. - On
callback(err, ...values): iferris truthy, reject witherr; else resolve. - A throw from
fnitself 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 oncallback(undefined)in some code and acceptscallback(0). Match truthy, and say so.- Arrow wrapper.
thisis lexical, methods break. - Spreading into
resolve(a, b).resolvetakes one value. Use an array or picka. - No
try/catcharoundfn.call. Sync throws must reject, not become an uncaught exception. - Assuming order of
thenvs a sync callback. Iffncallscbbefore 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 on this site
- Custom Promise from scratch
- Promise.all polyfill
- Cancelable async tasks
- Promises
- this binding rules
- call, apply, and bind
Related guides
- Run Async Tasks in ParallelStart every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- Run Async Tasks in SeriesExecute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.
- Retry a Promise N TimesRetry an async function with a bounded count, delay, and a shouldRetry predicate. Do not retry 4xx you caused, and never hide the last error.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.