Contents

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

1. You call writeFileSync twice on the same path. What is in the file?

Not quite. That is appendFileSync. Plain writing starts from empty every time.

Yes. The first contents are gone and not recoverable. Load, change in memory, write the whole thing back.

Not quite. An existing file is no protection. The second write replaces it completely.

2. const loaded = JSON.parse(text); with no annotation. What can you do with loaded?

Not quite. TypeScript will let you do absolutely anything to it, which is the problem rather than a restriction.

Yes. loaded.anything.at.all compiles clean. An annotation restores checking, though it does not verify the file matches.

Not quite. any permits everything, including calling things that do not exist.

3. You annotate a parsed file as Task[] and the file actually holds something else. When do you find out?

Not quite. The annotation is checked against your later code, never against the file. The file is not readable at compile time.

Yes. The annotation was a promise nobody verified. A file is a boundary TypeScript cannot see across, which is what chapter 19 is about.

Not quite. Nothing converts anything. The data is whatever was in the file, wearing a label that says otherwise.

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:

  1. Load. If tasks.json exists, read and parse it. If not, start with an empty array. This is where existsSync earns its place, since the very first run has no file.
  2. Do the work, entirely in memory. Add a task, mark one done, list them.
  3. Save. JSON.stringify the 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.js

Split 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.