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 (“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 works as follows:
null
. null
is a non-value that is similar to undefined
. undefined
is not supported by JSON.NaN
, +Infinity
, -Infinity
It’s unfortunate that JSON does not allow trailing commas in objects and arrays. That makes writing it by hand less convenient.
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.
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"]'
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:
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' ]
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.
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?
err
again – don’t handle it and we pass it on to the caller.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:
'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:
return
when we are done (line B).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()
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
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.
item-store.js
: Implement the subcommand del
that removes items.
.filter()
to remove the array element (only keep elements that don’t have the given index).-1
refers to the last element etc.