In Node, what contract must a function satisfy for util.promisify(fn) to produce a working promise version, and what is the util.promisify.custom symbol for?
answer
- it adapts a convention, nothing more
- callback last, error first
- only the second argument becomes the value
- non-conforming APIs register their own version
- a well-known symbol carries that registration
basics
~20 sutil.promisify expects a function whose last parameter is a callback invoked as (err, value); it returns a version that resolves with value or rejects with err. Functions that break that convention supply their own implementation under the util.promisify.custom symbol.
solid answer
~50 sThe contract is Node's error-first convention: the function must take its callback last, and that callback must be called with an error (or a falsy value) first and the result second. `util.promisify` then returns a function that forwards all other arguments, resolves with the callback's second argument, and rejects with the first when it is truthy. Two limits matter. Only the first success value survives, so a callback invoked as `(err, stdout, stderr)` would lose `stderr`; and functions that do not follow the convention at all — `fs.exists`, whose callback takes a bare boolean — cannot be promisified generically. That is what `util.promisify.custom` is for: a function can expose a purpose-built promise implementation under that symbol, and `promisify` returns it instead of generating a wrapper. `child_process.exec` and `fs.exists` both ship one. Where a first-party promise module exists, such as `node:fs/promises`, I use that rather than promisifying.
code
javascript · 13 linesimport { promisify } from 'node:util';
// legacy API: callback takes no error argument
function readSetting(key, cb) {
setTimeout(() => cb(key.length), 0);
}
// generic promisification would treat the value as an error, so register one
readSetting[promisify.custom] = (key) =>
new Promise((resolve) => readSetting(key, resolve));
const readSettingAsync = promisify(readSetting);
readSettingAsync('theme').then((n) => console.log('length', n));go deeper
Know that util.promisify turns an error-first callback function into one that returns a promise, and that Node also ships ready-made promise modules such as node:fs/promises you can import instead.
State the contract precisely — callback last, called as (err, value) — and explain that only the second argument becomes the fulfilment value, so multi-result callbacks lose data.
Explain when a generic wrapper is impossible and how util.promisify.custom lets a function ship its own promise form, citing the real cases Node registers internally for exec and exists.
Treat it as an API-evolution decision: a library exposing callbacks should ship both a promise-native surface and a promisify.custom registration, so consumers migrate without a compatibility shim in every caller.
## The convention promisify encodes `util.promisify` is Node's mechanised version of the hand-written error-first wrapper. It assumes exactly one thing about the function you hand it: the **last argument is a callback**, and that callback is invoked as `(err, value)` — a truthy first argument means failure, and a falsy one means the second argument is the result. ```js import { promisify } from 'node:util'; import fs from 'node:fs'; const readFile = promisify(fs.readFile); const text = await readFile('package.json', 'utf8'); ``` The returned function forwards every argument you pass, appends its own callback, and returns a promise that rejects with `err` or fulfils with `value`. Passing something that is not a function throws a `TypeError`. ## What the generated wrapper does and does not carry over **Only the first result survives.** The generated callback keeps the second argument and discards anything after it. For an API whose callback is `(err, a, b)`, the promisified form silently loses `b`. **The receiver comes from the call site.** The wrapper invokes the original with whatever `this` the wrapper itself was called on. So `obj.readIt = promisify(obj.readIt)` keeps working as a method, but pulling a method off an object and calling it bare loses the receiver exactly as the callback version would — bind it first if the implementation depends on `this`. **Own property descriptors are copied** from the original onto the wrapper, so incidental properties hanging off the function survive. ## Functions that do not fit Two classes of API break the contract: - Callbacks with **no error parameter**. The historical `fs.exists(path, (exists) => ...)` calls back with a boolean. Promisified generically, a `true` would be treated as an error and reject. - Callbacks with **several meaningful results**, like `child_process.exec(cmd, (err, stdout, stderr) => ...)`, where dropping `stderr` would be a real loss. ## util.promisify.custom For those cases the function itself can advertise a bespoke promise implementation: ```js import { promisify } from 'node:util'; function readSetting(key, cb) { // legacy: cb(value) with no error slot setTimeout(() => cb(key.length), 0); } readSetting[promisify.custom] = (key) => new Promise((resolve) => readSetting(key, resolve)); const readSettingAsync = promisify(readSetting); await readSettingAsync('theme'); // uses the custom implementation ``` `promisify.custom` is a well-known symbol — `Symbol.for('nodejs.util.promisify.custom')` — so the registration is cross-realm and any copy of `util` recognises it. When `promisify` sees that property it returns its value directly instead of generating a wrapper, and it throws a `TypeError` if the property is present but not a function. Node uses this internally: `child_process.exec` promisifies to a promise for `{ stdout, stderr }`, and `fs.exists` promisifies to a boolean-yielding function rather than an error-first one. The practical consequence for a library author is that shipping a `promisify.custom` implementation is how you make a legacy callback API behave sensibly under `await` without changing its signature. ## Prefer a first-party promise API Promisifying is a bridge, not a destination. Node now ships promise-native modules and you should reach for those first: ```js import { readFile } from 'node:fs/promises'; import { setTimeout as sleep } from 'node:timers/promises'; ``` `node:fs/promises`, `node:timers/promises`, and `node:dns/promises` are designed as promise APIs rather than translated ones, so they return richer objects, avoid per-call wrapper allocation, and accept options — such as an abort signal — that a mechanical translation of the callback form could never expose. ## The reverse direction `util.callbackify` goes the other way: it takes an `async` function and returns one that accepts an error-first callback, which is how you expose modern code to a legacy caller. Its one quirk is that if the async function rejects with a falsy reason, `callbackify` wraps it so the callback still receives a truthy error — otherwise the caller's `if (err)` check would miss the failure. ## What an interviewer is testing Mostly whether you know that promisification is a **convention adapter**, not magic: it works because Node standardised callback shape, it fails precisely where that standard was not followed, and the escape hatch is a documented symbol rather than a special case in the runtime.
- What happens to the extra values when a callback is invoked as (err, a, b)?The generated wrapper keeps only `a` and drops `b`, with no warning. If both values matter you must either write the wrapper yourself and resolve an object, or register an implementation under `util.promisify.custom`. Node does exactly that for `child_process.exec`, whose promisified form fulfils with `{ stdout, stderr }` instead of the bare stdout string.
- Why prefer node:fs/promises over promisify(fs.readFile)?It is a promise API by design rather than a translation. It avoids allocating a wrapper per call, returns the shapes the promise form actually wants, and exposes options a mechanical translation cannot — an abort signal, for instance. Promisifying stays useful for third-party or legacy callback APIs that ship no promise form of their own.
- What does util.callbackify do, and what is its one surprise?It converts an async function into an error-first callback function so modern code can be consumed by legacy callers. The surprise is falsy rejection reasons: if the async function rejects with something falsy, `callbackify` wraps it in an error object so the caller's `if (err)` test still fires, rather than passing the falsy value straight through and hiding the failure.
saying these in an interview costs you the question
- Thinks promisify works on any callback-taking function
- Expects all callback arguments to arrive in the resolved value
- Believes promisify is part of the ECMAScript standard
- Promisifies fs.readFile when fs/promises exists
- Extracts a method then wonders why this is undefined