Chapter 17
Files: Making Your Program Remember
Node's fs module, reading and writing.
Every program in this book so far forgets everything the moment it ends. Chapter 16’s deck, chapter 14’s high scores, chapter 12’s reading list: all gone, every time.
A file fixes that. You already have the tool.
The same function, a real path
Chapter 5 read from the keyboard with fs.readFileSync(0, "utf-8"), where
0 meant “the keyboard”. Give it a filename instead and it reads a file:
/// <reference types="node" />
const fs = require("fs");
fs.writeFileSync("notes.txt", "first line\nsecond line\n");
const text = fs.readFileSync("notes.txt", "utf-8");
console.log(text);
first line
second line
The same /// <reference types="node" /> line from chapter 5, for the same
reason. writeFileSync takes a path and some text and puts one in the
other. readFileSync takes it back out, as one string with the newlines
still in it, which is exactly what .split("\n") from chapter 5 is for.
Look in your folder and there’s a notes.txt sitting next to your .ts
file. Open it in an editor. It’s an ordinary text file with nothing special
about it, and that’s the point: your program’s memory is now something you
can read, back up, and email to somebody.
When the file isn’t there
const text = fs.readFileSync("does-not-exist.txt", "utf-8");
Error: ENOENT: no such file or directory, open 'does-not-exist.txt'
Your program stops. Not undefined, not an empty string, a genuine crash
with a stack trace.
That’s unusual for this book. Nearly every failure so far has been quiet:
undefined from an array, NaN from Number(), a wrong answer with no
complaint. This one is loud, and loud is easier.
ENOENT is short for “error: no entity”, and you’ll see it for the rest of
your career. It nearly always means a typo in the path, or a file you
expected somebody else to have created.
The fix is to look first:
if (fs.existsSync("notes.txt")) {
const text = fs.readFileSync("notes.txt", "utf-8");
console.log(text);
} else {
console.log("No notes yet.");
}
A program that saves its own data needs exactly this on the very first run, when the file it’s about to write doesn’t exist yet.
Storing something other than text
A file holds text. Chapter 12’s objects are not text, so something has to translate.
type Task = { title: string; done: boolean };
const tasks: Task[] = [
{ title: "Write chapter", done: true },
{ title: "Verify it", done: false },
];
fs.writeFileSync("tasks.json", JSON.stringify(tasks, null, 2));
console.log(fs.readFileSync("tasks.json", "utf-8"));
[
{
"title": "Write chapter",
"done": true
},
{
"title": "Verify it",
"done": false
}
]
JSON.stringify turns a value into text. The null, 2 at the end is
formatting, indent it by 2 spaces, and it’s worth the extra characters
because the file stays readable by a human. Leave them off and you get one
very long line, which is fine for a machine and miserable to debug.
JSON.parse goes the other way:
const loaded = JSON.parse(fs.readFileSync("tasks.json", "utf-8"));
console.log(loaded.length);
console.log(loaded[0].title);
2
Write chapter
Where TypeScript stops
Now the part of this chapter that matters most, and it’s uncomfortable.
Look again at what came back from JSON.parse:
const loaded = JSON.parse(fs.readFileSync("tasks.json", "utf-8"));
console.log(loaded.anything.at.all);
That compiles. No error, no warning, nothing.
JSON.parse returns any, chapter 3’s opt-out, because it genuinely
cannot know what’s in a file it hasn’t read yet. And any means every
guarantee you’ve built up since chapter 12 switches off at this line.
The obvious fix is to say what you expect:
const loaded: Task[] = JSON.parse(fs.readFileSync("tasks.json", "utf-8"));
console.log(loaded[0].titel);
error TS2551: Property 'titel' does not exist on type 'Task'.
Did you mean 'title'?
Checking is back. That looks like the problem solved, and it is worth doing, but be clear about what it actually did.
Paying chapter 11’s promise
Chapter 11 said console.log goes to stdout and console.error goes to
stderr, and that the difference would matter here. It does, because both
can be sent to files:
node report.js > out.txt 2> err.txt
out.txt: real output
err.txt: diagnostic
> redirects stdout, 2> redirects stderr, and they land in different
places without your program knowing or caring. That’s why the two channels
exist. A program can produce data on one and complaints on the other, and
whoever runs it decides where each goes.
It also means a program can write a file without using fs at all, by
printing and letting the shell catch it. Both are worth having.
Exercise 1 · Run it twice
Write a program that reads a number from count.txt, adds one, prints
it, and writes it back. Use existsSync so the first run starts at zero
instead of crashing.
Run it four times. It should print 1, 2, 3, 4.
That’s the whole idea of persistence in one program: the fourth run knows about the first three, and nothing was kept in memory to do it.
Check yourself
Project
A to-do list that survives quitting
Roughly 50 minutes
A program that keeps a to-do list in a file, so running it tomorrow finds what you wrote today.
Use chapter 12’s shape:
type Task = { title: string; done: boolean };The structure, and it’s the one to reuse for the rest of your life:
- Load. If
tasks.jsonexists, read and parse it. If not, start with an empty array. This is whereexistsSyncearns its place, since the very first run has no file. - Do the work, entirely in memory. Add a task, mark one done, list them.
- Save.
JSON.stringifythe whole array and write it once, at the end.
One read, one write, everything else in between. Never write inside a loop.
Take a command from the terminal, using chapter 5’s single read:
printf 'add Buy milk\n' | node todo.js
printf 'list\n' | node todo.js
printf 'done 1\n' | node todo.jsSplit the line, look at the first word, and use chapter 7’s if to pick
what to do. list should print every task with a number and a mark for
done ones.
The real test is running it more than once. Add three tasks in three separate runs, then list them. All three should be there. If they aren’t, you’re either not loading or writing inside the wrong step.
Then break it on purpose, because this is the chapter’s lesson. Open
tasks.json in an editor and change a task’s done from true to
"yes". Save it. Run list again.
Your program has a Task[] annotation saying done is a boolean.
TypeScript compiled it happily. Watch what the program does with a string
where it expected a boolean, remembering chapter 7’s rule that "yes" and
"false" are both truthy.
Nobody typed anything wrong in your TypeScript. The file changed, and the file was never checked.
Stretch: add a count command reporting how many tasks are done and
how many aren’t, using chapter 14’s .filter(). Then write the loading
step defensively: check Array.isArray on what came back from
JSON.parse before trusting it, and print a clear message instead of
crashing when the file holds nonsense. That’s the first honest step
toward chapter 19.