Learning web development: JSON and processing files in Node.js

[2025-08-31] dev, javascript, learning web dev
(Ad, please don’t block)

This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.

To download the projects, go to the GitHub repository learning-web-dev-code and follow the instructions there.

I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.


In this chapter, we explore the popular data format JSON. And we implement shell commands via Node.js that read and write files.

JSON  

JSON (“JavaScript Object Notation”) is a way of encoding data as text – e.g., in text files. Its syntax is a subset of JavaScript. In other words: Each piece of JSON data is valid JavaScript source code – it’s an expression. This is an example of a text file with JSON data:

{
  "first": "Jane",
  "last": "Porter",
  "married": true,
  "born": 1890,
  "friends": [ "Tarzan", "Cheeta" ]
}

The syntax of JSON  

The syntax of JSON works as follows:

  • JSON supports the following primitive values:
    • null. null is a non-value that is similar to undefined. undefined is not supported by JSON.
    • Booleans
    • Numbers, but not the error values NaN, +Infinity, -Infinity
    • Strings – which must be double-quoted. Single quotes and backticks are not allowed as delimiters.
  • Objects can be created via two literals:
    • Object literals create plain objects.
      • Property keys must be double-quoted. That’s a JavaScript feature we haven’t seen yet. It lets us use spaces and other normally illegal characters in property names.
      • Property values must be legal JSON values.
      • Trailing commas are not allowed.
    • Array literals create arrays.
      • The array elements must be legal JSON values.
      • Trailing commas are not allowed.

It’s unfortunate that JSON does not allow trailing commas in objects and arrays. That makes writing it by hand less convenient.

Parsing JSON via JSON.parse()  

Parsing JSON means converting text to a JavaScript value:

> JSON.parse('123')
123
> JSON.parse('"abc"')
'abc'
> JSON.parse('["dog", "cat"]')
[ 'dog', 'cat' ]

Parsing a JSON string that encodes a string is interesting because it’s a string that contains another string.

Converting JavaScript values to JSON via JSON.stringify()  

Stringifying to JSON means converting a JavaScript value to text:

> JSON.stringify(123)
'123'
> JSON.stringify('abc')
'"abc"'
> JSON.stringify(['dog', 'cat'])
'["dog","cat"]'

Stringifying with indentation  

We can also call JSON.stringify() like this:

JSON.stringify(value, null, indent)

Then the output will be more nicely formatted: Objects with at least one property and arrays with at least one element will take up multiple lines. indent is either:

  • A string that some lines will be indented with.
  • A number that specifies how many spaces are used to indent some lines.

This is an example:

const obj = {first: 'Jane', last: 'Porter'};
console.log(
  JSON.stringify(obj)
);
console.log(
  JSON.stringify(obj, null, 2)
);

Output:

{"first":"Jane","last":"Porter"}
{
  "first": "Jane",
  "last": "Porter"
}

We can see that two spaces were used to indent the two lines with the properties of the object.

array.slice()  

The array method .slice() creates a new array by copying a part of an existing array.

If called without arguments, it copies the whole array:

> ['a', 'b', 'c'].slice()
[ 'a', 'b', 'c' ]

If called with one index, it starts copying at the given index:

> ['a', 'b', 'c'].slice(2)
[ 'c' ]

If called with two indices, it starts copying at the first index and stops copying before the second index:

> ['a', 'b', 'c'].slice(1, 2)
[ 'b' ]

Reading and writing files in Node.js  

Node.js has a built-in module with functions for handling files:

import * as fs from 'node:fs';

In this chapter, we’ll use the following function to read files:

const str = fs.readFileSync(filePath, 'utf-8');

Given the path of a file, it returns a string with its contents.

The following function writes files:

fs.writeFileSync(filePath, str);

Given a path and a string, it creates a text file whose content is the provided string. If there already was a file at the filePath, it overwrites that file.

Project: item-store.js  

item-store.js is a shell command that adds strings to a JSON file:

item-store.js add <file-path> "string to add"

add is a so-called subcommand – with the idea of item-store.js being able to perform multiple operations. Initially, there is only the subcommand add; implementing the subcommand del will be an exercise later on.

Let’s look at how item-store.js works. First, we import fs – in preparation for later.

import * as fs from 'node:fs';

The following function reads a JSON file at a given path and returns a JavaScript value.

const readData = (filePath) => {
  try {
    const json = fs.readFileSync(filePath, 'utf-8');
    return JSON.parse(json);
  } catch (err) {
    if (err instanceof Error && err.code === 'ENOENT') {
      // File does not exist yet, start fresh
      return { // (A)
        items: [],
      };
    } else {
      throw err;
    }
  }
};

If the file already exists, everything inside the try block runs smoothly and a JavaScript value is returned. If, on the other hand, the file does not yet exist, we return default data (line A): an object with the property .items.

How exactly does the catch block work?

  • If the exception was not caused by a missing file, then we simply throw err again – don’t handle it and we pass it on to the caller.
  • How do we check that the exception was caused by a missing file? Exceptions thrown by Node.js are instances of Error and have a property .code that describes what caused the exception. In this case, I simply ran a command in the Node.js REPL that read a non-existent file and examined the resulting exception. Its .code property had the value 'ENOENT'. Alternatively, the Node.js documentation has a list of common error codes.

This function writes data to a JSON file:

const writeData = (filePath, data) => {
  const json = JSON.stringify(data, null, 2); // (A)
  fs.writeFileSync(filePath, json);
};

We indent the JSON by two spaces so that it looks nicer (line A).

The following helper function checks if our shell command gets enough arguments:

const minLenOrThrow = (args, minLen, subcommand) => {
  if (args.length < minLen) {
    throw new Error(
      `Subcommand ${subcommand} needs 2 arguments`
    );
  }
}

This is the core of our shell command – it uses all of the previous functions.

const SUBCMD_ADD = 'add'; // (A)

const main = () => {
  const args = process.argv.slice(2);
  if (args.length === 0) {
    console.log(
      `item-store.js ${SUBCMD_ADD} <file-path> "string to add"`
    );
    return; // (B)
  }
  // There is at least one argument
  const subcommand = args[0];
  if (subcommand === SUBCMD_ADD) {
    // 3 args: add <file-path> "string to add"
    minLenOrThrow(args, 3, SUBCMD_ADD);
    const filePath = args[1];
    const strToAdd = args[2];

    const data = readData(filePath);
    data.items.push(strToAdd);
    writeData(filePath, data);
  } else {
    throw new Error('Unknown subcommand: ' + subcommand);
  }
};

main();

What’s happening here?

  • First we use .slice() to create an array args with the actual arguments of the shell command. If the user provides zero arguments, the length of that array is zero.

  • If the user provided no arguments, we log a help text that explains how our shell command works.

  • The first argument names the subcommand. Instead of mentioning the string 'add' directly, we created a constant for it (line A). That has three benefits:

    • We are less likely to make a typo. If we do then JavaScript warns us about it.
    • It’s easier to change subcommand names later on.
    • The variable name (and, potentially a comment above its declaration) provide an additional explanation for what the string 'add' means. This kind of explanation is even more useful if the value is a number.
  • For SUBCMD_ADD, we read the file, push a string to the array in property .item and write the data back to storage.

The core of the shell command is wrapped in the function main(). That has two benefits:

  • We can return when we are done (line B).
  • It neatly packages the core – versus it loosely flowing around in the module.

Note that the following functions were created not just for add but with future additional subcommands in mind. We’ll use them in an exercise later in this chapter:

  • minLenOrThrow()
  • readData()
  • writeData()

URL objects  

The built-in class URL is a container for URLs. It lets us access various parts of URL – e.g., consider the following URL object:

new URL('http://example.com:8080/home.html')

It has these properties (and more):

{
  href: 'http://example.com:8080/home.html',
  protocol: 'http:',
  host: 'example.com:8080',
  hostname: 'example.com',
  port: '8080',
  pathname: '/home.html',
}

Another useful functionality of URL is that it lets us combine a full URL with a so-called relative reference (think relative or absolute path with slashes as separators):

> new URL('image.jpg', 'http://example.com/dir/index.html').href
'http://example.com/dir/image.jpg'
> new URL('img/image.jpg', 'http://example.com/dir/index.html').href
'http://example.com/dir/img/image.jpg'
> new URL('../image.jpg', 'http://example.com/dir/index.html').href
'http://example.com/image.jpg'

This kind of combining is called resolving a relative reference against a base URL.

import.meta.url  

import.meta.url contains a string with the URL of the currently running module. Let’s use log-import-meta-url.js to check out what it looks like:

console.log(import.meta.url);

Running this file produces output that looks like this:

file:///tmp/learning-web-dev-code/projects/log-import-meta-url.js

Project: random-quote-nodejs/  

This project is a shell command that retrieves a random quote from a JSON file and logs it to the terminal.

random-quote-nodejs/quotes.json  

The JSON file with the quotes looks like this:

[
  {
    "quote": "...",
    "author": "..."
  },
  // ...
]

Note that JSON does not support comments but we used one here, anyway – for explanatory purposes.

Source of the quotes: “Quote of the Day” by Wikiquote

random-quote-nodejs/random-quote-nodejs.js  

The JSON file with the quotes sits next to the shell command. The following command creates a URL for that file:

const fileUrl = new URL('quotes.json', import.meta.url);

We resolve the filename 'quotes.json' against the base URL import.meta.url of our shell command. Conveniently, most fs functions accept URL objects as an alternative to strings with file system paths – we use that in line A:

const main = () => {
  const json = fs.readFileSync(fileUrl, 'utf-8'); // (A)
  const quotes = JSON.parse(json);
  
  const randomIndex = getRandomInteger(quotes.length);
  const randomQuote = quotes[randomIndex];
  
  console.log(randomQuote.quote);
  console.log('— ' + randomQuote.author);
};

getRandomInteger() is the function that we have already used several times before.

Exercise (without solution)  

  • item-store.js: Implement the subcommand del that removes items.
    • Its first argument is a file path. Its second argument is an index for the array element to be removed.
    • Use .filter() to remove the array element (only keep elements that don’t have the given index).
    • Optional: Accept negative indices so that -1 refers to the last element etc.