Files
Problem
The program forgets
You are writing a program for your expenses. You enter them in the evening and close it, and the next day it knows nothing about yesterday. Here is the shortest example:
let visits = 0;visits++;console.log(`Started ${visits} time.`);Started 1 time.No matter how many times you run it, it says "1 time". Variables live in memory while the program runs. When it ends, the memory is freed and everything disappears.
To remember, the program has to write the data where it stays after the program ends: in a file on the disk.
The idea
A file with node:fs
Here is a whole program that writes a note to a file and reads it back:
import { readFileSync, writeFileSync } from "node:fs";
try { writeFileSync("notes.txt", "Buy milk\nPay the power bill by the 15th\n"); const text = readFileSync("notes.txt", "utf8"); process.stdout.write(text);} catch (e) { console.log(`Problem with the file: ${e.message}`);}Buy milkPay the power bill by the 15thimport { readFileSync, writeFileSync } from "node:fs";takes two functions from thenode:fsmodule (file system). It comes with Node and you do not install it.writeFileSync(name, text)writes the text and creates the file if it is not there."notes.txt"is a file with this name in the folder the program was started from.readFileSync(name, "utf8")reads the whole file as text."utf8"is the encoding: how bytes become letters.- "Sync" means that the program waits until the file is written or read, and only then carries on.
- If something goes wrong (the disk is full, the file is locked or missing), the function throws an error. JavaScript does not make you catch it, but without
catchthe program stops. process.stdout.write(text)prints the text as it is.console.logwould add one more new line at the end.
Run the program, then open the folder: notes.txt is there, and you can open it with any text editor. The next run will find it.
In every example from here on, the imports are at the top: from the module you take only the functions you use.
The idea
Add and read the lines
Expenses are added one by one. appendFileSync adds at the end, instead of writing the file from scratch. split("\n") splits the text you read into lines:
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
try { writeFileSync("expenses.txt", "Lunch;12.50\n"); appendFileSync("expenses.txt", "Taxi;8.20\n"); appendFileSync("expenses.txt", "Book;18.90\n"); const lines = readFileSync("expenses.txt", "utf8").trimEnd().split("\n"); let total = 0; for (const line of lines) { const parts = line.split(";"); total += Number(parts[1]); console.log(`${parts[0]}: €${parts[1]}`); } console.log(`${lines.length} expenses, total €${total.toFixed(2)}`);} catch (e) { console.log(`Problem with the file: ${e.message}`);}Lunch: €12.50Taxi: €8.20Book: €18.903 expenses, total €39.60lines is an array of strings, one for each line. trimEnd() before split removes the last new line character. Without it, the array would have one more element at the end: an empty string "".
More precisely, if you are curious
appendFileSynccreates the file if it is not there, so the first append gives no error.- Without
"utf8",readFileSyncreturns aBuffer: the bytes of the file themselves, for example<Buffer 43 61 66 c3 a9 ...>. It becomes text only with the encoding. - There is also a version where the program does not stop and wait for the disk:
import { readFile } from "node:fs/promises";andconst text = await readFile("expenses.txt", "utf8");. Servers use that one. In short programs,readFileSyncis simpler. readFileSyncreads the whole file into memory. For a file of several gigabytes it is better to read line by line. See the card on streams.
Try it
Two shifts
What will the program print?
import { readFileSync, writeFileSync } from "node:fs";
try { writeFileSync("shift.txt", "First shift: Ivo\n"); writeFileSync("shift.txt", "Second shift: Mila\n"); process.stdout.write(readFileSync("shift.txt", "utf8"));} catch (e) { console.log(e.message);}writeFileSync deletes the old contents and writes the new ones. The first line is gone even before the reading. This is the most common reason for "my file got erased".
The idea
When the file is not there
The program's settings are in config.txt, and someone deleted it. If nobody catches the exception:
import { readFileSync } from "node:fs";
function readConfig() { return readFileSync("config.txt", "utf8");}
console.log(readConfig());node:fs:440 return binding.readFileUtf8(path, stringToFlags(options.flag)); ^
Error: ENOENT: no such file or directory, open 'config.txt' at readFileSync (node:fs:440:20) at readConfig (file:///app/app.js:4:10) at file:///app/app.js:7:13 ... { errno: -2, code: 'ENOENT', syscall: 'open', path: 'config.txt'}At the top, Node shows the line from its own internal code where the error was thrown. Then comes the error itself: ENOENT (error, no entry) is the code for "no such file or directory". The lines with "at" go from the inside out: readFileSync, then readConfig on line 4, called from line 7. The lines of Node after them are shortened with "...".
The errors of node:fs are of the kind Error, and they differ by e.code. Check the code in catch:
import { existsSync, readFileSync } from "node:fs";
function readConfig() { return readFileSync("config.txt", "utf8");}
try { console.log(readConfig());} catch (e) { if (e.code === "ENOENT") { console.log(`No file ${e.path}. Using the default settings.`); } else { console.log(`Cannot read the settings: ${e.message}`); }}console.log(existsSync("config.txt"));No file config.txt. Using the default settings.falsee.path is the name of the missing file. existsSync(path) checks whether the file exists. The check does not replace catch: the file may disappear between the check and the reading, and there are other reasons the reading may fail.
The idea
Line by line and streams
writeFileSync writes the whole text at once. For writing in parts and for reading line by line, there are streams. A stream keeps the file open while you work with it, so it has to be closed at the end:
import { createReadStream, createWriteStream } from "node:fs";import { once } from "node:events";import { createInterface } from "node:readline";
try { const out = createWriteStream("report.txt"); for (let day = 1; day <= 3; day++) { out.write(`Day ${day}: €${day * 120}\n`); } out.end(); await once(out, "close");} catch (e) { console.log(`Cannot write: ${e.message}`);}
try { const lines = createInterface({ input: createReadStream("report.txt") }); for await (const line of lines) { console.log(line); }} catch (e) { console.log(`Cannot read: ${e.message}`);}Day 1: €120Day 2: €240Day 3: €360createWriteStreamopens the file for writing.writegives the text to the stream. A new line does not come by itself: you write the\n.end()says "that is all" and starts the closing.await once(out, "close")waits until the stream writes everything and closes the file.onceis from thenode:eventsmodule.createInterfacefromnode:readlinereads the stream from the file line by line, andfor awaitgives the lines one by one, without the new line character. This is the same thing you use to read from the keyboard.
Closing is not a formality: until the stream is closed, the text may still be in its buffer in memory, not on the disk. To keep the code short, there is no try here:
import { createWriteStream, readFileSync, statSync } from "node:fs";import { once } from "node:events";
const out = createWriteStream("report.txt");for (let day = 1; day <= 3; day++) { out.write(`Day ${day}: €${day * 120}\n`);}console.log(`In the buffer: ${out.writableLength} bytes`);out.end();await once(out, "close");console.log(`In the file: ${statSync("report.txt").size} bytes`);for (const line of readFileSync("report.txt", "utf8").trimEnd().split("\n")) { console.log(line);}Step by step, in words
- node:fs gives the stream and the size of the file, node:events the waiting. On the left is the memory of the program, on the right is the disk.
- createWriteStream creates a stream with a buffer in memory. The file report.txt opens without the program waiting.
- write gives the text to the stream, and it waits in the buffer. Nothing reaches the disk yet.
- writableLength: 42 bytes wait in the buffer. Not a single one is on the disk.
- end() closes the stream, and await waits for that to happen. The buffer goes to the disk: the file becomes 42 bytes.
- 42 bytes for 36 characters with the new lines: in UTF-8 each Latin letter is 1 byte, and € is 3.
- readFileSync reads the three lines back from the disk.
JavaScript has no try (...) that closes a resource by itself. readFileSync and writeFileSync do not need one, because they open and close the file themselves. When you keep the file open, the closing goes in finally, from the lesson on finally:
import { closeSync, openSync, writeSync } from "node:fs";
const fd = openSync("report.txt", "w");try { writeSync(fd, "Day 1: €120\n");} finally { closeSync(fd);}openSync opens the file and returns its number (fd, file descriptor). writeSync writes straight to the file, with no buffer. closeSync closes it, whatever happens in the try.
More precisely, if you are curious
- The language is getting
using: a resource that closes by itself at the end of the block, liketry (...)in Java. Node 22 does not recognize it yet and gives aSyntaxError. - Buffer. The stream collects the text in memory (
out.writableLengthis how many bytes are waiting) and sends it to the disk without the program stopping to wait. That is why you need anawaitafterend(). readlineovercreateReadStreamis the way for a very big file: only the current chunk is in memory. Here it is on its own:
import { createReadStream, writeFileSync } from "node:fs";import { createInterface } from "node:readline";
writeFileSync("report.txt", "Day 1: €120\nDay 2: €240\n");const lines = createInterface({ input: createReadStream("report.txt") });for await (const line of lines) { console.log(line);}Try it
When does it close?
JavaScript has no try (...), so the resource is closed in finally. Here is the receipt printer from the lesson on finally: close() is in the finally of the inner try, and the error is caught by the outer one:
What will the program print?
class Printer { constructor() { console.log("The printer is busy."); }
close() { console.log("The printer is free."); }}
try { const printer = new Printer(); try { console.log("Printing the receipt..."); const qty = Number("three"); if (Number.isNaN(qty)) { throw new Error("three is not a number"); } console.log(`Quantity: ${qty}`); } finally { printer.close(); }} catch (e) { console.log("Wrong quantity.");}Number("three") gives NaN with no error, so the check throws by itself. The "Quantity" line is skipped. The finally of the inner try runs right away, even before the outer catch. That is why "free" comes before "Wrong quantity." By the time catch runs, the printer is already released.Try it
Find the bug
1, but there are two lines. Which line has the bug?Pick the line with the bug.
end() only starts the closing. The writing happens later, while readFileSync reads right away, when only the header line is in the file. The program gives no error. It quietly counts wrong. After out.end(); add await once(out, "close"); (once is from node:events): then the stream is closed and there are two lines.The idea
Folders
The register's backups go into a folder, one per day:
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";import { join } from "node:path";
const backup = "backup";try { mkdirSync(join(backup, "old"), { recursive: true }); writeFileSync(join(backup, "2026-10-01.csv"), "Café,2.40\n"); writeFileSync(join(backup, "2026-10-02.csv"), "Café,2.40\nTea,1.80\n"); for (const name of readdirSync(backup).sort()) { const p = join(backup, name); if (statSync(p).isDirectory()) { console.log(`${name}/`); } else { console.log(`${name} (${statSync(p).size} bytes)`); } } unlinkSync(join(backup, "2026-10-01.csv")); console.log(existsSync(join(backup, "2026-10-01.csv")));} catch (e) { console.log(e.message);}2026-10-01.csv (11 bytes)2026-10-02.csv (20 bytes)old/false| Function | What it does |
|---|---|
mkdirSync(p, { recursive: true }) |
creates the folder and all the folders above it, if they are not there |
join(dir, "name") (from node:path) |
the path to name inside dir |
statSync(p).isDirectory(), existsSync(p) |
is it a folder, is it there |
statSync(p).size |
the size in bytes |
unlinkSync(p) |
deletes a file (rmdirSync(p) deletes an empty folder) |
readdirSync(dir) |
an array with the names of everything in the folder |
The sizes are 11 and 20 bytes, though there are fewer characters: in UTF-8, the "é" in "Café" is 2 bytes.
readdirSync does not promise an order for the names, so we sort them with sort().More precisely, if you are curious
rmdirSyncdeletes only an empty folder. Otherwise it throws an error with the codeENOTEMPTY.rmSync(p, { recursive: true })deletes the folder with everything in it, so be careful with it.copyFileSync(src, dst)copies a file, andrenameSync(src, dst)moves or renames it.joinputs the system's separator between the parts:/on Linux and macOS,\on Windows. That is why you do not build paths with+.- The path
"notes.txt"is relative: to the folder the program was started from."/home/ana/notes.txt"is absolute.resolve("notes.txt")fromnode:pathshows exactly where the file is.
Exercise
Programs that remember
Solve the tasks in your editor. In each task, the program first creates the input file itself with writeFileSync, so you can try it right away.
1. A configuration file. Create server.conf with these contents, then read it and print each setting as key: value. Skip the empty lines and the lines that start with #. Hint: find the first = with indexOf and take the two parts with slice. split("=", 2) does not help here: in JavaScript it throws away everything after the second =.
# server settingsmax_connections=500log_file_path=/var/log/server.log
db_timeout=30max_connections: 500log_file_path: /var/log/server.logdb_timeout: 302. A log file filter. Create app.log with four lines (two INFO and two ERROR). Read a level from the keyboard, for example ERROR, and write the lines with that level to errors.log line by line: read with readline, write with a stream (createWriteStream). Print errors.log and how many lines you wrote.
ERRORERROR: Failed to load configurationERROR: Null pointer exceptionWrote 2 lines to errors.log3. Folder contents. Create a folder project with the files document.txt, image.jpg, script.js and the folder backup. Write their names, sorted in alphabetical order, to directory_contents.txt, with a / after the names of folders. Print the file.
backup/document.txtimage.jpgscript.js4. An index file. Create text.txt with the three lines below. Read a word from the keyboard and write to index.txt each place it appears, in the form line:position: the line number (from 1) and the index in the line (from 0). If the word is on one line twice, that is two entries.
hello worldworld hellohello again, hellohello1:02:63:03:13More tasks
- Run counter. Fix the program from the first card: count the runs in
visits.txt. If the file is not there, start from 0. - Find and replace in a file. The task from the lesson "Search and replace": read a file, replace "old" with "new", and write the result to a new file.
- Dates from a file. The date task from the lesson "Regular expressions": read the dates from a file, one per line.
- A copy with line numbers. Read a file and write a copy in which each line starts with its number:
1: ...,2: ....
Test