skip to content

In Node, fs.readFile(path, encoding, callback) reports its result through an error-first callback (err, data). How would you wrap that call so it returns a Promise instead, and what rules keep such a wrapper correct?

level: juniorimportance: must knowfreq 70%

answer

  1. translate a convention, do not invent one
  2. first callback argument decides the branch
  3. reject on truthy err, else resolve
  4. executor runs immediately and settles once
  5. the platform may already ship promises

basics

~10 s

Call the original inside new Promise and translate its callback: reject(err) when the first argument is truthy, otherwise resolve(data). The executor runs immediately, must settle exactly once, and should contain nothing but that translation.

solid answer

~50 s

I return `new Promise((resolve, reject) => { ... })` and call `fs.readFile` inside the executor, translating the callback: if the first argument is truthy I `reject(err)`, otherwise I `resolve(data)`. Three rules keep it honest. The executor body runs synchronously, before the constructor returns, so the read is already in flight — `await` only subscribes to a result that is coming anyway. It must settle exactly once; `resolve` and `reject` are one-shot and later calls are silently ignored, which is why a double callback from the underlying API leaves no trace. And the executor should do nothing but the translation: only its synchronous part is protected, so parsing or logging inside the callback can throw where no `catch` is listening. In real code I would reach for `node:fs/promises` or `util.promisify` first; hand-written wrappers are for APIs that ship no promise form.

code

javascript · 15 lines
javascript
import fs from 'node:fs';

function readFileAsync(path, encoding) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, encoding, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

readFileAsync('package.json', 'utf8')
  .then((text) => JSON.parse(text))
  .then((pkg) => console.log(pkg.name))
  .catch((err) => console.error('failed:', err.message));

go deeper

for a junior

Be ready to write the wrapper from memory: new Promise, call the original inside, reject when the first callback argument is truthy, otherwise resolve. Say plainly that the first callback parameter is the error.

for a middle

Explain that the executor body runs synchronously so the work is already in flight, and that resolve and reject are one-shot, with any later call silently ignored rather than raising an error.

for a senior

Show where the executor's error protection stops: a throw from the callback one tick later escapes as an uncaught exception and leaves the promise pending forever. Keep parsing and logging out of the executor.

for a principal

Own the policy: hand-written wrappers are a last resort. Steer the codebase to node:fs/promises and util.promisify so settle-once discipline lives in one reviewed helper rather than in dozens of copy-pasted executors.

## What "error-first" means Node's traditional asynchronous APIs share one calling convention: the last argument is a function invoked with the failure as its first parameter and the successful result after it. `fs.readFile('a.txt', 'utf8', (err, data) => ...)` calls back either with `err` set to an `Error` and `data` undefined, or with `err` null and `data` holding the contents. Exactly one of those happens, and it happens exactly once. Promisifying means turning that convention into the other one: a rejected promise for the first case, a fulfilled promise for the second. ## The wrapper ```js import fs from 'node:fs'; function readFileAsync(path, encoding) { return new Promise((resolve, reject) => { fs.readFile(path, encoding, (err, data) => { if (err) reject(err); else resolve(data); }); }); } ``` The whole job of the executor — the function passed to `new Promise` — is that translation. It receives two functions from the constructor: `resolve`, which fulfils the promise with a value, and `reject`, which rejects it with a reason. Every path through the callback must reach exactly one of them. ## The executor runs synchronously The executor is invoked immediately by the `Promise` constructor, before `new Promise` returns and therefore before `readFileAsync` returns. So the read starts at the moment you call the function, not when someone attaches `.then` or `await`. Promises are not lazy tasks: they are handles on work that is already running. That is worth saying out loud in an interview, because a common wrong model is that `await` "starts" the operation. ## Settle exactly once Once a promise is settled its state is fixed. Calling `resolve` a second time, or calling `reject` after `resolve`, does nothing at all — no exception, no warning, no console output. That immutability is a feature: a consumer can never see a promise flip from fulfilled to rejected. It is also a hazard for a wrapper author, because it hides bugs. If the underlying API mistakenly calls back twice, or you forget the `else` so a truthy `err` runs both branches, the wrapper looks fine and the second signal simply vanishes. When you suspect a misbehaving callback API, add an explicit `let settled = false` guard that logs rather than swallowing. ## Keep the executor thin The constructor wraps only the *synchronous* run of the executor in an implicit try/catch: if the executor throws before it returns, the promise rejects with that value. The moment the callback fires — one or more turns of the event loop later — that protection is gone. A throw inside the callback escapes into the host: in Node it surfaces as an uncaught exception, which by default prints the stack and exits the process, and the promise stays pending forever. So resist the temptation to do work there: ```js // don't parse inside the executor's callback readFileAsync('config.json', 'utf8').then(text => JSON.parse(text)); ``` A `JSON.parse` failure in the `.then` becomes a normal rejection that `catch` can handle; the same failure inside the executor's callback does not. ## Callbacks that are not error-first Some APIs take two separate callbacks, one for success and one for failure. Those wrap even more tersely, because `resolve` and `reject` are ordinary functions you can hand over directly: ```js function load(id) { return new Promise((resolve, reject) => api.load(id, resolve, reject)); } ``` The only caution is that `resolve` accepts a single value, so an API that calls its success callback with several arguments loses all but the first unless you wrap them yourself: `(a, b) => resolve({ a, b })`. ## Prefer the promise API that already exists Most of the time you should not write this code at all. Node ships promise-native modules — `node:fs/promises`, `node:timers/promises`, `node:dns/promises` — and `util.promisify` mechanises the error-first case for anything else. Hand-rolling is for third-party or legacy APIs with no promise form. And never wrap something that already returns a promise: `return original()` is the correct "wrapper". ## The generic helper Written out, the generic version is the same three lines applied to any arity: ```js function promisify(fn) { return (...args) => new Promise((resolve, reject) => { fn(...args, (err, value) => (err ? reject(err) : resolve(value))); }); } ``` That toy version already shows why the built-in exists: it drops any extra callback arguments, and it does not forward the receiver, so promisifying a method and calling it detached loses `this`.

  • Does the underlying read start when the promise is created or when you await it?
    When the promise is created. The executor runs synchronously inside `new Promise`, so `fs.readFile` is called before the wrapper function returns. Awaiting later only subscribes to a result already on its way. This is why two wrappers created back to back run concurrently, and why storing a promise and awaiting it much later still gives you the value that was fetched at creation time.
  • What happens if the wrapped API invokes your callback twice?
    Nothing visible. The first `resolve` or `reject` fixes the promise's state permanently; the second call is ignored with no exception and no warning. The consumer sees one settlement and the duplicate signal disappears. If you suspect a buggy API, keep a `settled` flag in the closure and log or throw on the second invocation so the defect is observable instead of silent.
  • How would you wrap an API that takes separate success and failure callbacks instead of an error-first one?
    Hand `resolve` and `reject` straight to it: `new Promise((resolve, reject) => api.load(id, resolve, reject))`. They are plain functions, so no extra closure is needed. The one thing to watch is arity — `resolve` keeps only its first argument, so if the success callback is invoked with several values, collect them yourself with `(a, b) => resolve({ a, b })`.

saying these in an interview costs you the question

  • Says the wrapped work starts only when you await it
  • Calls both resolve and reject expecting the last one to win
  • Wraps a function that already returns a promise
  • Parses or validates inside the executor callback and loses the throw
  • Thinks a throw from the async callback rejects the promise

context