Warm-up · Activity 1 of 7
// B3.3 · ~34 min · Beginner
Writing, appending and atomic replace
After this lesson you can write, append and refuse to overwrite files, save readable JSON, and replace a file so that it never holds half a write.
You will be able to
- Replace a file with writeFile, add to it with appendFile, and refuse to overwrite with the wx flag
- Save a value as readable JSON, into a folder that mkdir creates when it is missing
- Replace a file atomically: write a temp file in the same folder, then rename it over the target
Predict · Activity 2 of 7
Predict before you read on. notes.txt holds the line buy milk. What does this program print?
import {readFile, writeFile} from 'node:fs/promises'; await writeFile('notes.txt', 'call Ada\n'); console.log(await readFile('notes.txt', 'utf8'));Practice · Activity 3 of 7
app.log already holds started. Fill in the function so that stopped is added as a second line and started stays.
await ____("app.log", "stopped\n");await ("app.log", "stopped\n");Practice · Activity 4 of 7
f.txt holds old. Match each flag of writeFile("f.txt", "new\n", {flag}) to what happens.
Practice · Activity 5 of 7
Fill in the third argument so that settings.json is indented by two spaces per level, and ends with a newline.
await writeFile("settings.json", JSON.stringify(settings, null, ____) + "\n");await writeFile("settings.json", JSON.stringify(settings, null, ) + "\n");Brain teaser · Activity 6 of 7
Brain teaser. settings.json holds {"theme": "light"}. The program below saves the new settings with a temp file, but dies before the rename. What does settings.json hold afterwards?
import {rename, writeFile} from 'node:fs/promises'; await writeFile('.settings.json.tmp', '{"theme": "dark"}\n'); throw new Error('power cut'); // the program dies here await rename('.settings.json.tmp', 'settings.json');Apply · Activity 7 of 7
Mini-task. Write main.js so that node main.js "buy milk" adds the note to data/notes.json next to main.js, a JSON array indented by two spaces. A missing folder or file must simply start an empty list. Replace notes.json with a temp file and rename, append a line added: buy milk to data/notes.log, and print how many notes there are. Run it twice and open both files.
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
A log, a refusal and a settings file
This program creates the folder out next to main.js and writes a log there: writeFile starts it, appendFile adds two lines. A second writeFile with the wx flag refuses to overwrite the log and reports EEXIST. Last, it saves settings as indented JSON the safe way, through a temp file and rename, and lists the folder: no temp file is left. Run it twice and the output stays the same, because writeFile starts the log afresh.
main.js
import {appendFile, mkdir, readFile, readdir, rename, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
const dir = join(import.meta.dirname, 'out');
await mkdir(dir, {recursive: true}); // fine if out already exists
// writeFile replaces, appendFile adds.
const log = join(dir, 'app.log');
await writeFile(log, 'started\n');
await appendFile(log, 'saved settings\n');
await appendFile(log, 'stopped\n');
console.log((await readFile(log, 'utf8')).trimEnd());
// 'wx' refuses to overwrite an existing file.
try {
await writeFile(log, 'oops\n', {flag: 'wx'});
} catch (error) {
console.log('wx:', error.code);
}
// JSON, replaced in one step: temp file in the same folder, then rename.
const settings = join(dir, 'settings.json');
const temp = join(dir, `.settings.json.${process.pid}.tmp`);
await writeFile(temp, JSON.stringify({theme: 'dark', fontSize: 14}, null, 2) + '\n');
await rename(temp, settings);
console.log((await readFile(settings, 'utf8')).trimEnd());
console.log('in out:', (await readdir(dir)).sort().join(', '));
Run it with
node main.jsOutput
started
saved settings
stopped
wx: EEXIST
{
"theme": "dark",
"fontSize": 14
}
in out: app.log, settings.json- appendFile added two lines after started, because each piece of data ends with "\n".
- With {flag: 'wx'}, writeFile rejects with EEXIST and app.log keeps its three lines.
- JSON.stringify(value, null, 2) put each property on its own line, two spaces deep.
- The temp file was renamed over settings.json, so the folder holds only the two real files.
Exercises
Exercise 1 of 2
A temp name next to the target
Before an atomic replace, you need a temp path in the same folder as the target. Write tempPathFor(target, id): put a dot before the file name and .<id>.tmp after it, and keep the folder part unchanged. "data/notes.json" with id 42 gives "data/.notes.json.42.tmp"; "notes.json" gives ".notes.json.42.tmp". Paths use / here. This part is pure string work, so it 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
target.lastIndexOf("/") finds the last slash; it is -1 when there is none.
Hint 2
Adding 1 gives the index where the file name starts, also 0 when there is no slash.
Hint 3
target.slice(0, cut) is the folder part with its slash, target.slice(cut) the file name.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// The temp path for an atomic replace: same folder, a dot before the name,
// .<id>.tmp after it. "data/notes.json", 42 -> "data/.notes.json.42.tmp"
export function tempPathFor(target, id) {
const cut = target.lastIndexOf("/") + 1;
return target.slice(0, cut) + "." + target.slice(cut) + "." + id + ".tmp";
}
console.log(tempPathFor("data/notes.json", 42));
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
// The temp path for an atomic replace: same folder, a dot before the name,
// .<id>.tmp after it. "data/notes.json", 42 -> "data/.notes.json.42.tmp"
export function tempPathFor(target, id) {
return target + ".tmp";
}
console.log(tempPathFor("data/notes.json", 42));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {tempPathFor} from './main.js';
test('a file in a folder keeps its folder', () => {
const got = tempPathFor('data/notes.json', 42);
assert.equal(got, 'data/.notes.json.42.tmp', `tempPathFor returned ${JSON.stringify(got)}`);
});
test('a file without a folder', () => {
const got = tempPathFor('notes.json', 7);
assert.equal(got, '.notes.json.7.tmp', `tempPathFor returned ${JSON.stringify(got)}`);
});
test('a deep absolute path', () => {
const got = tempPathFor('/home/ada/app/data/notes.json', 1234);
assert.equal(got, '/home/ada/app/data/.notes.json.1234.tmp', `tempPathFor returned ${JSON.stringify(got)}`);
});
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
saveJson, safely
saveJson(path, value) writes value straight into path, and it fails because the folder data does not exist yet. Make it safe: create the folder of path with mkdir and {recursive: true}, write JSON.stringify(value, null, 2) plus "\n" to a temp file in that folder, such as .notes.json.<process.pid>.tmp, then rename the temp file over path. The code below it saves twice and prints the file and the folder. Run node main.js and 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
dirname(path) is the folder; mkdir(dirname(path), {recursive: true}) does nothing when it already exists.
Hint 2
Build the temp path in the same folder: join(dir, `.${basename(path)}.${process.pid}.tmp`).
Hint 3
writeFile(temp, JSON.stringify(value, null, 2) + "\n"), then rename(temp, path).
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import {mkdir, readFile, readdir, rename, writeFile} from 'node:fs/promises';
import {basename, dirname, join} from 'node:path';
// Saves value as indented JSON, replacing path in one step.
export async function saveJson(path, value) {
const dir = dirname(path);
await mkdir(dir, {recursive: true});
const temp = join(dir, `.${basename(path)}.${process.pid}.tmp`);
await writeFile(temp, JSON.stringify(value, null, 2) + '\n');
await rename(temp, path);
}
const file = join(import.meta.dirname, 'data', 'notes.json');
await saveJson(file, {notes: ['buy milk']});
await saveJson(file, {notes: ['buy milk', 'call Ada']});
console.log((await readFile(file, 'utf8')).trimEnd());
console.log('ends with a newline:', (await readFile(file, 'utf8')).endsWith('\n'));
console.log('files in data:', (await readdir(dirname(file))).sort().join(', '));
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 {mkdir, readFile, readdir, rename, writeFile} from 'node:fs/promises';
import {basename, dirname, join} from 'node:path';
// Saves value as indented JSON, replacing path in one step.
export async function saveJson(path, value) {
await writeFile(path, JSON.stringify(value));
}
const file = join(import.meta.dirname, 'data', 'notes.json');
await saveJson(file, {notes: ['buy milk']});
await saveJson(file, {notes: ['buy milk', 'call Ada']});
console.log((await readFile(file, 'utf8')).trimEnd());
console.log('ends with a newline:', (await readFile(file, 'utf8')).endsWith('\n'));
console.log('files in data:', (await readdir(dirname(file))).sort().join(', '));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
test('runs and creates the folder data', async () => {
await runMain();
});
test('notes.json holds the second save, indented by two spaces', async () => {
const got = (await runMain()).split('\n').slice(0, 6).join('\n');
const expected = '{\n "notes": [\n "buy milk",\n "call Ada"\n ]\n}';
assert.equal(got, expected, `the file starts with ${JSON.stringify(got)}`);
});
test('notes.json ends with a newline', async () => {
const out = await runMain();
assert.match(out, /ends with a newline: true/, 'the file does not end with "\\n"');
});
test('no temp file is left in data', async () => {
const last = (await runMain()).trimEnd().split('\n').at(-1);
assert.equal(last, 'files in data: notes.json', `the folder holds: ${last}`);
});
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
Writing an object instead of text
import {writeFile} from 'node:fs/promises';
await writeFile('settings.json', {theme: 'dark'});
What Node.js prints
TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received an instance of ObjectWhy, and the fix
writeFile writes text or bytes, and since v14 it no longer turns other values into strings for you. Turn the value into JSON first: writeFile("settings.json", JSON.stringify(settings, null, 2) + "\n").
Writing into a folder that does not exist
import {writeFile} from 'node:fs/promises';
await writeFile('data/notes.json', '[]\n');
What Node.js prints
Error: ENOENT: no such file or directory, open 'data/notes.json'Why, and the fix
writeFile creates the file, but not the folders on its way. Create the folder first: await mkdir("data", {recursive: true}). With recursive it creates every missing parent and does not fail when the folder is already there.
Renaming onto the folder instead of the file
import {mkdir, rename, writeFile} from 'node:fs/promises';
await mkdir('data', {recursive: true});
await writeFile('.notes.json.tmp', '[]\n');
await rename('.notes.json.tmp', 'data'); // meant data/notes.json
What Node.js prints
Error: EISDIR: illegal operation on a directory, rename '.notes.json.tmp' -> 'data'Why, and the fix
rename takes the full new path of the file, not the folder it should land in. rename overwrites an existing file, but if there is a folder at the new path it raises an error instead. Write rename(".notes.json.tmp", "data/notes.json").
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.