Implement Node's util.promisify. You take a function written in the error-first callback style — fn(arg1, arg2, ..., (err, value) => { ... }) — and return a new function with the same arguments minus the callback. Calling the new function returns a Promise that fulfils with value when the original callback gets (null, value) and rejects with err when it gets (err).
This is the bridge between two eras of JavaScript: the old callback-passing world and the modern async/await world. Once you've written promisify, every legacy callback API you touch becomes await-able.
// Wraps a Node-style (err, value) callback function and returns
// a function that returns a Promise instead.
function promisify(original: (...args: any[]) => void):
(...args: any[]) => Promise<any>;
The original function must follow the convention: the last argument is a callback invoked as callback(err) on failure or callback(null, value) on success.
// fs.readFile-style API → awaitable.
function readFile(path, cb) {
if (!path) cb(new Error('no path'));
else cb(null, 'file contents');
}
const readFileAsync = promisify(readFile);
await readFileAsync('./hello.txt'); // 'file contents'
await readFileAsync(''); // throws Error('no path')
// Forwards all arguments to the original. Preserves `this`.
const api = {
multiplier: 10,
scale(n, cb) { cb(null, n * this.multiplier); },
};
api.scaleAsync = promisify(api.scale);
await api.scaleAsync.call(api, 4); // 40
// Multiple calls return independent promises.
const p1 = readFileAsync('a.txt');
const p2 = readFileAsync('b.txt');
// p1 and p2 settle independently of each other.
(err, value). If err is truthy, reject with err; otherwise resolve with value. undefined and other falsy values for err mean success.this. If the wrapped function is called as wrapped.call(ctx, ...) or as a method (obj.wrapped(...)), the original must see the same this. Arrow functions inside the wrapper will break this — use a regular function.try/catch so a synchronous throw from the original becomes a rejection rather than escaping to the caller.(err, value). If the original calls back with extra arguments (cb(null, a, b, c)), resolve with just the first value (a). The multi-value flavour is util.promisify's [promisify.custom] territory — out of scope here.new Promise(...). Don't memoise across calls.You'll write a higher-order function that takes a Node-style callback function and returns a new function that produces a Promise instead — turning every legacy callback API into something await-able.
Before promises, Node had one calling convention for async work: pass a callback as the last argument, and the callback gets invoked as (err, value) — error first, value second. Modern code uses async/await over promises, so we want a one-line bridge: hand promisify any function in the old style, get back a new function in the new style. The wrapper has to forward all the original arguments, preserve this (so methods still work), and translate the (err, value) callback into resolve(value) or reject(err).
promisify is a function factory. It runs once and returns a new function — call that returned function as many times as you want, and each call builds a fresh Promise. Inside that promise's executor, you call the original with the original's arguments plus one extra: a callback that translates (err, value) into reject(err) or resolve(value). The error-first convention is the whole contract you're translating.
The instinct is to write the smallest possible wrapper. Arrow functions look concise; let's start there:
function naive(original) {
return (...args) => new Promise((resolve, reject) => {
original(...args, (err, value) => {
if (err) reject(err);
else resolve(value);
});
});
}
It works for free functions. Pass readFile, get readFileAsync, await readFileAsync('./hi.txt') — fine. But the moment the original function relies on this, the wrapper silently breaks:
const api = {
factor: 10,
scale(n, cb) { cb(null, n * this.factor); },
};
api.scaleAsync = naive(api.scale);
await api.scaleAsync(4); // TypeError: Cannot read properties of undefined (reading 'factor')
Because the returned wrapper is an arrow function, this inside it is captured from the enclosing scope (the module's this, which is undefined in strict mode), not from the caller. When we then call original(...args, ...), original runs with this === undefined. The method that worked perfectly fine when called the old way is broken the moment we promisify it. Tests that pass .call(ctx, ...) will catch this immediately.
function promisify(original) {
// Return a REGULAR function — not an arrow — so the wrapper has its
// own `this` that mirrors whatever the caller used (.call/.apply/method).
return function (...args) {
// Each call gets its own Promise. `resolve` and `reject` are captured
// by the callback below via closure, so they're scoped to THIS call only.
return new Promise((resolve, reject) => {
// try/catch so a synchronous throw from the original becomes a
// rejection instead of an exception that escapes the wrapper.
try {
// Forward original args, then APPEND the error-first callback.
// original.call(this, ...) — not original(...) — propagates whatever
// `this` the wrapper was called with into the original function.
original.call(this, ...args, (err, value) => {
// Node convention: `if (err)` uses truthiness. null, undefined,
// 0, '', false, NaN all mean success — anything else means failure.
if (err) {
reject(err);
} else {
// Drop any callback args beyond `value` — Node's util.promisify
// only takes the first; multi-value flavour needs [promisify.custom].
resolve(value);
}
});
} catch (err) {
reject(err);
}
});
};
}
module.exports = { promisify };
Three deliberate moves separate this from the naive version. First, return function (...) instead of return (...) => ... — that's the entire fix for this. A regular function gets its this set at call time from how it's called; an arrow function never gets one. Second, original.call(this, ...args, cb) instead of original(...args, cb) — without .call(this, ...), even a regular wrapper would lose the binding the moment it forwards. Third, the try/catch — without it, an original that throws synchronously (throw new Error('bad path') before ever invoking the callback) would propagate the exception out of new Promise(...)'s executor, which the Promise constructor catches and converts to a rejection automatically — but only inside the executor. If we moved the call outside the new Promise, the throw would escape. The try/catch makes the intent explicit and survives refactors.
Trace wrapped.call(api, 4) where api = { factor: 10, scale(n, cb) { cb(null, n * this.factor); } } and wrapped = promisify(api.scale).
Step 1 — entering the wrapper. wrapped is the regular function returned by promisify. .call(api, 4) invokes it with this === api and args === [4].
Step 2 — constructing the Promise. We enter new Promise((resolve, reject) => { ... }). The executor runs synchronously. resolve and reject are now bound to this specific Promise instance — they live in the executor's closure.
Step 3 — the try block runs. We call original.call(this, ...args, callback). Substituting: api.scale.call(api, 4, callback). Inside scale, this === api, n === 4, cb === callback. The body executes cb(null, n * this.factor) → callback(null, 4 * 10) → callback(null, 40).
Step 4 — the callback fires. Inside our callback: err === null, value === 40. if (err) is false (null is falsy). We hit the else branch and call resolve(40). The promise's resolution is queued for the next microtask.
Step 5 — the executor returns. No exception was thrown, so the catch doesn't fire. The wrapper returns the pending-to-fulfilled Promise.
Step 6 — the caller awaits. Whoever called wrapped.call(api, 4) does await on the returned Promise. On the next microtask, it sees the value 40.
Now imagine a parallel second call: wrapped.call(api, 7) issued one line later. It enters a completely fresh execution of the wrapper, builds a new Promise with its own resolve and reject, schedules its own callback. The two calls share original and the wrapper's code, but they share nothing else — no shared promise, no shared callbacks, no shared state.
(...args) => new Promise(...) looks clean and breaks this. The moment a caller does wrapped.call(ctx, ...) or treats the wrapper as a method, the original sees the wrong this (or undefined in strict mode) and either misbehaves or throws Cannot read properties of undefined. The fix is the boring one — write function (...args) {}.original(...args, cb) instead of original.call(this, ...args, cb). Even with a regular function wrapper, a plain call forwards no this. The original runs in whatever default context applies (the global object in sloppy mode, undefined in strict mode). Always thread this explicitly with .call.err == null or err !== null instead of if (err). Node's convention is truthiness. Callers pass null or undefined interchangeably for success; an explicit equality check rejects when one passes undefined and accidentally treats false or 0 as errors. Stick to if (err) reject(err); else resolve(value);.try/catch around the call. If the original throws synchronously before invoking the callback (e.g. argument validation: if (!path) throw ...), the throw escapes through the Promise constructor's executor and is converted to a rejection automatically — so this often hides. But the moment you refactor (extract the call outside new Promise, or build a custom thenable), the throw escapes to the caller and breaks the promise contract. Wrap it defensively.cb(null, a, b, c) is sometimes used by libraries. resolve(a, b, c) only honours a; the rest are dropped (Promises fulfil with a single value). If you naively resolve(...rest) you get the same result, just more clutter — but tests that expect resolve(a) will pass and tests that expect a multi-arg shape will fail. Match the spec: first value only.this. Every call must produce a brand-new Promise.util.promisify.custom. Node's real util.promisify looks for a [util.promisify.custom] symbol on the original function. If present, it returns the function under that symbol instead of building one — this lets library authors expose a hand-tuned promise variant (e.g. one that resolves with multiple values packed into an object). Implementing it is one extra check at the top of promisify: if (original[Symbol.for('nodejs.util.promisify.custom')]) return that;.promisifyAll(obj) helper. Walks every own function property of an object and replaces each with its promisified counterpart under a new name suffix (e.g. fs.readFile → fs.readFileAsync). Useful for legacy libraries with dozens of callback methods. The work is mostly bookkeeping — naming, skipping non-functions, not double-promisifying. The core stays the same.callbackify(asyncFn), the inverse. Takes an async function (or any promise-returning function) and returns a Node-style callback function. You'd attach .then(value => cb(null, value), err => cb(err)) to the returned promise. This is what you'd write when you need to expose modern code through an older callback-based API surface.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement Node's util.promisify. You take a function written in the error-first callback style — fn(arg1, arg2, ..., (err, value) => { ... }) — and return a new function with the same arguments minus the callback. Calling the new function returns a Promise that fulfils with value when the original callback gets (null, value) and rejects with err when it gets (err).
This is the bridge between two eras of JavaScript: the old callback-passing world and the modern async/await world. Once you've written promisify, every legacy callback API you touch becomes await-able.
// Wraps a Node-style (err, value) callback function and returns
// a function that returns a Promise instead.
function promisify(original: (...args: any[]) => void):
(...args: any[]) => Promise<any>;
The original function must follow the convention: the last argument is a callback invoked as callback(err) on failure or callback(null, value) on success.
// fs.readFile-style API → awaitable.
function readFile(path, cb) {
if (!path) cb(new Error('no path'));
else cb(null, 'file contents');
}
const readFileAsync = promisify(readFile);
await readFileAsync('./hello.txt'); // 'file contents'
await readFileAsync(''); // throws Error('no path')
// Forwards all arguments to the original. Preserves `this`.
const api = {
multiplier: 10,
scale(n, cb) { cb(null, n * this.multiplier); },
};
api.scaleAsync = promisify(api.scale);
await api.scaleAsync.call(api, 4); // 40
// Multiple calls return independent promises.
const p1 = readFileAsync('a.txt');
const p2 = readFileAsync('b.txt');
// p1 and p2 settle independently of each other.
(err, value). If err is truthy, reject with err; otherwise resolve with value. undefined and other falsy values for err mean success.this. If the wrapped function is called as wrapped.call(ctx, ...) or as a method (obj.wrapped(...)), the original must see the same this. Arrow functions inside the wrapper will break this — use a regular function.try/catch so a synchronous throw from the original becomes a rejection rather than escaping to the caller.(err, value). If the original calls back with extra arguments (cb(null, a, b, c)), resolve with just the first value (a). The multi-value flavour is util.promisify's [promisify.custom] territory — out of scope here.new Promise(...). Don't memoise across calls.You'll write a higher-order function that takes a Node-style callback function and returns a new function that produces a Promise instead — turning every legacy callback API into something await-able.
Before promises, Node had one calling convention for async work: pass a callback as the last argument, and the callback gets invoked as (err, value) — error first, value second. Modern code uses async/await over promises, so we want a one-line bridge: hand promisify any function in the old style, get back a new function in the new style. The wrapper has to forward all the original arguments, preserve this (so methods still work), and translate the (err, value) callback into resolve(value) or reject(err).
promisify is a function factory. It runs once and returns a new function — call that returned function as many times as you want, and each call builds a fresh Promise. Inside that promise's executor, you call the original with the original's arguments plus one extra: a callback that translates (err, value) into reject(err) or resolve(value). The error-first convention is the whole contract you're translating.
The instinct is to write the smallest possible wrapper. Arrow functions look concise; let's start there:
function naive(original) {
return (...args) => new Promise((resolve, reject) => {
original(...args, (err, value) => {
if (err) reject(err);
else resolve(value);
});
});
}
It works for free functions. Pass readFile, get readFileAsync, await readFileAsync('./hi.txt') — fine. But the moment the original function relies on this, the wrapper silently breaks:
const api = {
factor: 10,
scale(n, cb) { cb(null, n * this.factor); },
};
api.scaleAsync = naive(api.scale);
await api.scaleAsync(4); // TypeError: Cannot read properties of undefined (reading 'factor')
Because the returned wrapper is an arrow function, this inside it is captured from the enclosing scope (the module's this, which is undefined in strict mode), not from the caller. When we then call original(...args, ...), original runs with this === undefined. The method that worked perfectly fine when called the old way is broken the moment we promisify it. Tests that pass .call(ctx, ...) will catch this immediately.
function promisify(original) {
// Return a REGULAR function — not an arrow — so the wrapper has its
// own `this` that mirrors whatever the caller used (.call/.apply/method).
return function (...args) {
// Each call gets its own Promise. `resolve` and `reject` are captured
// by the callback below via closure, so they're scoped to THIS call only.
return new Promise((resolve, reject) => {
// try/catch so a synchronous throw from the original becomes a
// rejection instead of an exception that escapes the wrapper.
try {
// Forward original args, then APPEND the error-first callback.
// original.call(this, ...) — not original(...) — propagates whatever
// `this` the wrapper was called with into the original function.
original.call(this, ...args, (err, value) => {
// Node convention: `if (err)` uses truthiness. null, undefined,
// 0, '', false, NaN all mean success — anything else means failure.
if (err) {
reject(err);
} else {
// Drop any callback args beyond `value` — Node's util.promisify
// only takes the first; multi-value flavour needs [promisify.custom].
resolve(value);
}
});
} catch (err) {
reject(err);
}
});
};
}
module.exports = { promisify };
Three deliberate moves separate this from the naive version. First, return function (...) instead of return (...) => ... — that's the entire fix for this. A regular function gets its this set at call time from how it's called; an arrow function never gets one. Second, original.call(this, ...args, cb) instead of original(...args, cb) — without .call(this, ...), even a regular wrapper would lose the binding the moment it forwards. Third, the try/catch — without it, an original that throws synchronously (throw new Error('bad path') before ever invoking the callback) would propagate the exception out of new Promise(...)'s executor, which the Promise constructor catches and converts to a rejection automatically — but only inside the executor. If we moved the call outside the new Promise, the throw would escape. The try/catch makes the intent explicit and survives refactors.
Trace wrapped.call(api, 4) where api = { factor: 10, scale(n, cb) { cb(null, n * this.factor); } } and wrapped = promisify(api.scale).
Step 1 — entering the wrapper. wrapped is the regular function returned by promisify. .call(api, 4) invokes it with this === api and args === [4].
Step 2 — constructing the Promise. We enter new Promise((resolve, reject) => { ... }). The executor runs synchronously. resolve and reject are now bound to this specific Promise instance — they live in the executor's closure.
Step 3 — the try block runs. We call original.call(this, ...args, callback). Substituting: api.scale.call(api, 4, callback). Inside scale, this === api, n === 4, cb === callback. The body executes cb(null, n * this.factor) → callback(null, 4 * 10) → callback(null, 40).
Step 4 — the callback fires. Inside our callback: err === null, value === 40. if (err) is false (null is falsy). We hit the else branch and call resolve(40). The promise's resolution is queued for the next microtask.
Step 5 — the executor returns. No exception was thrown, so the catch doesn't fire. The wrapper returns the pending-to-fulfilled Promise.
Step 6 — the caller awaits. Whoever called wrapped.call(api, 4) does await on the returned Promise. On the next microtask, it sees the value 40.
Now imagine a parallel second call: wrapped.call(api, 7) issued one line later. It enters a completely fresh execution of the wrapper, builds a new Promise with its own resolve and reject, schedules its own callback. The two calls share original and the wrapper's code, but they share nothing else — no shared promise, no shared callbacks, no shared state.
(...args) => new Promise(...) looks clean and breaks this. The moment a caller does wrapped.call(ctx, ...) or treats the wrapper as a method, the original sees the wrong this (or undefined in strict mode) and either misbehaves or throws Cannot read properties of undefined. The fix is the boring one — write function (...args) {}.original(...args, cb) instead of original.call(this, ...args, cb). Even with a regular function wrapper, a plain call forwards no this. The original runs in whatever default context applies (the global object in sloppy mode, undefined in strict mode). Always thread this explicitly with .call.err == null or err !== null instead of if (err). Node's convention is truthiness. Callers pass null or undefined interchangeably for success; an explicit equality check rejects when one passes undefined and accidentally treats false or 0 as errors. Stick to if (err) reject(err); else resolve(value);.try/catch around the call. If the original throws synchronously before invoking the callback (e.g. argument validation: if (!path) throw ...), the throw escapes through the Promise constructor's executor and is converted to a rejection automatically — so this often hides. But the moment you refactor (extract the call outside new Promise, or build a custom thenable), the throw escapes to the caller and breaks the promise contract. Wrap it defensively.cb(null, a, b, c) is sometimes used by libraries. resolve(a, b, c) only honours a; the rest are dropped (Promises fulfil with a single value). If you naively resolve(...rest) you get the same result, just more clutter — but tests that expect resolve(a) will pass and tests that expect a multi-arg shape will fail. Match the spec: first value only.this. Every call must produce a brand-new Promise.util.promisify.custom. Node's real util.promisify looks for a [util.promisify.custom] symbol on the original function. If present, it returns the function under that symbol instead of building one — this lets library authors expose a hand-tuned promise variant (e.g. one that resolves with multiple values packed into an object). Implementing it is one extra check at the top of promisify: if (original[Symbol.for('nodejs.util.promisify.custom')]) return that;.promisifyAll(obj) helper. Walks every own function property of an object and replaces each with its promisified counterpart under a new name suffix (e.g. fs.readFile → fs.readFileAsync). Useful for legacy libraries with dozens of callback methods. The work is mostly bookkeeping — naming, skipping non-functions, not double-promisifying. The core stays the same.callbackify(asyncFn), the inverse. Takes an async function (or any promise-returning function) and returns a Node-style callback function. You'd attach .then(value => cb(null, value), err => cb(err)) to the returned promise. This is what you'd write when you need to expose modern code through an older callback-based API surface.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.