Warm-up · Activity 1 of 7
// B4.1 · ~32 min · Beginner
From callbacks to promises
After this lesson you can read and write error-first callbacks, and turn any callback API into a promise you can await: with a built-in promise API, util.promisify, or a wrapper of your own.
Lesson 1 of 5 in B4 Asynchronous Node
You will be able to
- Read and write error-first callbacks, and explain why they nest and why try/catch misses their errors
- Use the promise APIs of node:fs/promises and node:timers/promises, and util.promisify where it fits
- Wrap a callback API by hand with new Promise, and call a callback exactly once
Predict · Activity 2 of 7
Predict before you read on. later() calls its callback with an error after 10 ms. What does this program print?
function later(callback) { setTimeout(() => callback(new Error('disk full')), 10); } try { later((err) => { if (err) throw err; }); console.log('started'); } catch (error) { console.log('caught', error.message); }Practice · Activity 3 of 7
readFile and promisify are imported from node:fs and node:util. Fill in the function that turns the callback readFile into one that returns a promise.
const readFileAsync = ____(readFile); const text = await readFileAsync('notes.txt', 'utf8'); console.log(text.trim());const readFileAsync = (readFile);Practice · Activity 4 of 7
Each callback API on the left has a promise form. Match each one to the promise form you should use.
Practice · Activity 5 of 7
lookup() calls its callback with the result only, not error-first. What does this program print?
import {promisify} from 'node:util'; function lookup(name, callback) { setTimeout(() => callback(name.toUpperCase()), 10); } const lookupAsync = promisify(lookup); try { console.log(await lookupAsync('ada')); } catch (error) { console.log('rejected with', error); }Brain teaser · Activity 6 of 7
Brain teaser. parsePort follows the error-first convention, or so it seems. What does this program print?
function parsePort(text, callback) { const port = Number(text); if (!Number.isInteger(port)) callback(new Error('bad port: ' + text)); callback(null, port); } parsePort('abc', (err, port) => { if (err) console.log('error:', err.message); else console.log('port', port); });Apply · Activity 7 of 7
Mini-task. In main.js, write readSetting(name, callback): it reads settings.json with readFile from node:fs, parses it, and calls callback(null, value) for that key, or callback(err) for a missing file or broken JSON, exactly once in each case. Then make readSettingAsync = promisify(readSetting), await it in try/catch and print theme: dark, or the error's code or name. Try it with settings.json present, missing and broken.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
One task in callbacks, promisify and fs/promises
first.txt names a second file, and the program reads both, in order, three ways. With callbacks, the second read nests inside the first. A small promise wrapped by hand lets top-level await wait for the callback version. Then util.promisify(readFile) and readFile from node:fs/promises do the same with await, flat. setTimeout from node:timers/promises fulfils with the value you give it, and a missing file arrives as a rejection that try/catch can handle.
main.js
import {readFile} from 'node:fs';
import {readFile as readFilePromise} from 'node:fs/promises';
import {setTimeout as delay} from 'node:timers/promises';
import {promisify} from 'node:util';
// 1. Error-first callbacks: the second read nests inside the first.
function viaCallbacks(done) {
readFile('first.txt', 'utf8', (err, next) => {
if (err) return done(err);
readFile(next.trim(), 'utf8', (err, message) => {
if (err) return done(err);
done(null, message.trim());
});
});
}
// 2. Wrapped by hand, so that await can wait for it.
const first = await new Promise((resolve, reject) => {
viaCallbacks((err, message) => (err ? reject(err) : resolve(message)));
});
console.log('callbacks:', first);
// 3. util.promisify: the callback is last and error-first, so it fits.
const readFileAsync = promisify(readFile);
const next = await readFileAsync('first.txt', 'utf8');
console.log('promisify:', (await readFileAsync(next.trim(), 'utf8')).trim());
// 4. The built-in promise API: nothing to wrap.
const again = await readFilePromise('first.txt', 'utf8');
console.log('fs/promises:', (await readFilePromise(again.trim(), 'utf8')).trim());
console.log('timers/promises:', await delay(20, 'waited 20 ms'));
try {
await readFilePromise('missing.txt', 'utf8');
} catch (error) {
console.log('rejected with', error.code);
}
first.txt
second.txt
second.txt
Hello from second.txt
Run it with
node main.jsOutput
callbacks: Hello from second.txt
promisify: Hello from second.txt
fs/promises: Hello from second.txt
timers/promises: waited 20 ms
rejected with ENOENT- The callback version needs two levels of nesting for two steps; the await versions stay flat.
- The hand-made promise passes the error to reject and the message to resolve, so both outcomes reach await.
- promisify(readFile) and readFile from node:fs/promises give the same text; prefer the built-in one when it exists.
- delay(20, value) fulfils with value once 20 ms have passed.
Exercises
Exercise 1 of 2
Your own promisify
findUser(id, callback) is a callback API: after 10 ms it calls callback(null, user) for id 1, and callback(error) for any other id. Write toPromise(fn): it returns a function that takes the same arguments as fn, minus the callback, and returns a promise. The promise fulfils with the value and rejects with the error the callback receives. It must work for any function with an error-first callback last, such as add(a, b, callback). This part runs in the browser too.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
The returned function must return new Promise((resolve, reject) => { ... }).
Hint 2
Call fn inside the executor, with ...args and then your own callback as the last argument.
Hint 3
In that callback: if (err) reject(err); else resolve(value);
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// A callback API, as older libraries have it: error first, value second.
export function findUser(id, callback) {
setTimeout(() => {
if (id === 1) callback(null, {id: 1, name: 'Ada'});
else callback(new Error('no user ' + id));
}, 10);
}
// Returns a promise version of fn. fn takes an error-first callback as its last argument.
export function toPromise(fn) {
return (...args) =>
new Promise((resolve, reject) => {
fn(...args, (err, value) => {
if (err) reject(err);
else resolve(value);
});
});
}
const findUserAsync = toPromise(findUser);
console.log(await findUserAsync(1));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// A callback API, as older libraries have it: error first, value second.
export function findUser(id, callback) {
setTimeout(() => {
if (id === 1) callback(null, {id: 1, name: 'Ada'});
else callback(new Error('no user ' + id));
}, 10);
}
// Returns a promise version of fn. fn takes an error-first callback as its last argument.
export function toPromise(fn) {
return (...args) => {
fn(...args, (err, value) => {
// settle a promise here
});
};
}
const findUserAsync = toPromise(findUser);
console.log(await findUserAsync(1));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {findUser, toPromise} from './main.js';
test('the new function returns a promise', () => {
const result = toPromise(findUser)(1);
assert.equal(typeof result?.then, 'function', 'toPromise(findUser)(1) should return a promise');
});
test('the promise fulfils with the value from the callback', async () => {
const user = await toPromise(findUser)(1);
assert.deepEqual(user, {id: 1, name: 'Ada'}, `expected {id: 1, name: 'Ada'}, got ${JSON.stringify(user)}`);
});
test('the promise rejects with the error from the callback', async () => {
await assert.rejects(async () => toPromise(findUser)(2), {message: 'no user 2'}, 'toPromise(findUser)(2) should reject with the error "no user 2"');
});
test('every argument is passed on, with the callback last', async () => {
const add = (a, b, callback) => setTimeout(() => callback(null, a + b), 1);
const sum = await toPromise(add)(2, 3);
assert.equal(sum, 5, `toPromise(add)(2, 3) should fulfil with 5, got ${sum}`);
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 2 of 2
Count lines with a promise
countLines(path, callback) counts the non-empty lines of a file with the callback readFile. Turn it into countLines(path), which returns a promise: it fulfils with the count, and rejects with the read error unchanged, so a missing file rejects with ENOENT. Use readFile from node:fs/promises (or util.promisify). Then change the last lines to await it, so that node main.js still prints notes.txt: 3 lines. Check with node --test.
This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.
Hints
Hint 1
import {readFile} from 'node:fs/promises' gives you a readFile that returns a promise.
Hint 2
Make countLines an async function: const text = await readFile(path, "utf8"); then return the count.
Hint 3
Don't catch the error inside countLines: a rejection passes to the caller unchanged. At the end: console.log('notes.txt:', await countLines('notes.txt'), 'lines');
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import {readFile} from 'node:fs/promises';
// Fulfils with the number of non-empty lines in the file at path.
export async function countLines(path) {
const text = await readFile(path, 'utf8');
return text.split('\n').filter((line) => line !== '').length;
}
console.log('notes.txt:', await countLines('notes.txt'), 'lines');
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
import {readFile} from 'node:fs';
// Calls back with the number of non-empty lines in the file at path.
export function countLines(path, callback) {
readFile(path, 'utf8', (err, text) => {
if (err) return callback(err);
callback(null, text.split('\n').filter((line) => line !== '').length);
});
}
countLines('notes.txt', (err, count) => {
if (err) return console.error(err.message);
console.log('notes.txt:', count, 'lines');
});
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
import {countLines} from './main.js';
test('countLines(path) returns a promise of the count', async () => {
const result = countLines('notes.txt');
assert.equal(typeof result?.then, 'function', 'countLines("notes.txt") should return a promise');
assert.equal(await result, 3, 'notes.txt has 3 non-empty lines');
});
test('a missing file rejects with ENOENT', async () => {
await assert.rejects(async () => countLines('missing.txt'), {message: /ENOENT/}, 'countLines("missing.txt") should reject with the ENOENT error');
});
test('the program prints notes.txt: 3 lines', async () => {
const got = (await runMain()).trim();
assert.equal(got, 'notes.txt: 3 lines', `the program printed ${JSON.stringify(got)}`);
});
notes.txt
buy milk
water plants
call Ada
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsCommon mistakes
Awaiting the callback form of readFile
import {readFile} from 'node:fs';
const text = await readFile('notes.txt', 'utf8');
console.log(text);
What Node.js prints
TypeError [ERR_INVALID_ARG_TYPE]: The "cb" argument must be of type function. Received type string ('utf8')Why, and the fix
readFile from node:fs is the callback form: its last argument must be a function, and here it is the string 'utf8'. It never returns the text or a promise, so await has nothing to wait for. Import the promise form instead: import {readFile} from 'node:fs/promises'. Or keep node:fs and pass a callback.
Checking err but going on anyway
import {readFile} from 'node:fs';
readFile('missing.txt', 'utf8', (err, text) => {
if (err) console.error('could not read missing.txt');
console.log(text.trim());
});
What Node.js prints
TypeError: Cannot read properties of undefined (reading 'trim')Why, and the fix
The error is noticed and reported, but nothing stops the callback, so the next line runs with text undefined and crashes. Leave the callback after handling the error: if (err) { console.error(...); return; }. An else around the success path works too.
Promisifying a method without its object
import {promisify} from 'node:util';
class Store {
constructor() {
this.data = {theme: 'dark'};
}
get(key, callback) {
callback(null, this.data[key]);
}
}
const store = new Store();
const get = promisify(store.get);
console.log(await get('theme'));
What Node.js prints
TypeError: Cannot read properties of undefined (reading 'data')Why, and the fix
promisify(store.get) takes the function alone, not the object it belongs to, so this is undefined when get runs. The docs warn about methods that use this. Bind the object first: promisify(store.get.bind(store)), or call the result with .call(store, "theme").
JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.