Learn / JavaScript fundamentals
LESSON 13 OF 84

JavaScript fundamentals Foundation

Modern JavaScript

Take a piece of old-style JavaScript and rewrite it step by step with the modern syntax a backend uses every day, fixing real bugs on the way.

  • 35 min to read and try
  • You need: Objects and JSON, and the lessons before it
  • You build: A task formatter refactored from old-style code to modern JavaScript, with its hidden bugs fixed
Test yourself

The code you will modernise

JavaScript has changed a lot since 2015. You will still meet old-style code: in older projects, in answers online, and in code written by people who learned JavaScript long ago. You need to read both styles, and write the modern one.

This lesson does not introduce a new topic. It takes one piece of old-style code and improves it, one tool at a time. Here is the starting point: a function that turns a task into a line of text for a report.

old.js
function formatTask(task, options) {
  options = options || {};
  var prefix = options.prefix || "-";
  var owner = task.owner && task.owner.name ? task.owner.name : "nobody";
  var priority = task.priority || 3;
  return prefix + " " + task.title + " (" + owner + ", priority " + priority + ")";
}

console.log(formatTask({ title: "Buy milk", owner: { name: "Ada" }, priority: 1 }));
console.log(formatTask({ title: "Write report" }, { prefix: "*" }));
console.log(formatTask({ title: "Fix login bug", priority: 0 }, { prefix: "" }));
Output of node old.js and of the browser terminal
- Buy milk (Ada, priority 1)
* Write report (nobody, priority 3)
- Fix login bug (nobody, priority 3)

The first two lines look right. The third one is wrong twice:

  • The task has priority 0 (the most urgent), but the report says 3.
  • The caller asked for no prefix (""), but got -.

Both bugs come from ||, which you met in the operators lesson. a || b gives b whenever a is falsy, and 0 and "" are falsy. Keep this in mind: the modern version will fix it.

Template literals

Gluing strings with + is hard to read and easy to get wrong: a missing space, a missing quote. A template literal uses backticks ` instead of quotes. Inside it, ${ } inserts the value of any expression. It can also span several lines:

template.js
const title = "Buy milk";
const priority = 1;

console.log("Task: " + title + " (priority " + priority + ")");
console.log(`Task: ${title} (priority ${priority})`);
console.log(`Urgent: ${priority <= 1 ? "yes" : "no"}`);

const report = `Report
  ${title}
  done: ${false}`;
console.log(report);
Output of node template.js and of the browser terminal
Task: Buy milk (priority 1)
Task: Buy milk (priority 1)
Urgent: yes
Report
  Buy milk
  done: false

The first two lines print the same text. The template literal is easier to read because the text looks like the result. Any expression works inside ${ }, even the ternary ? : from the conditions lesson.

Optional chaining and nullish coalescing

Two operators replace most of the && and || tricks in old code.

  • Optional chaining a?.b: if a is null or undefined, stop and give undefined instead of crashing. Otherwise read a.b as usual. It also works for calls, a.b?.(), and brackets, a?.[key].
  • Nullish coalescing a ?? b: give b only when a is null or undefined. Unlike ||, it keeps 0, "" and false.
nullish.js
const task = { title: "Fix login bug", priority: 0, owner: null };

console.log(task.owner?.name);
console.log(task.owner?.name ?? "nobody");
console.log(task.notify?.());

console.log(task.priority || 3);
console.log(task.priority ?? 3);
console.log("" || "-", "" ?? "-");

const settings = { pageSize: 0 };
settings.pageSize ??= 20;
settings.theme ??= "light";
console.log(settings);
Output of node nullish.js and of the browser terminal
undefined
nobody
undefined
3
0
-
{ pageSize: 0, theme: 'light' }

Line by line:

  • task.owner is null, so task.owner?.name is undefined instead of a TypeError. Adding ?? "nobody" gives a fallback.
  • task.notify does not exist, so task.notify?.() does not call anything.
  • || threw away the real priority 0. ?? kept it. The same for the empty string: the second value on that line is "", printed as nothing.
  • a ??= b means "set a to b only if a is null or undefined". pageSize: 0 stayed, the missing theme was filled in.

TIP

Rule of thumb: when you want a default for a missing value, use ??. Use || only when you really mean "any falsy value".

Default parameters

Old code filled in missing arguments by hand: options = options || {}. Now a parameter can have a default value written right in the function's first line:

defaults.js
function paginate(items, page = 1, size = 2) {
  const start = (page - 1) * size;
  return items.slice(start, start + size);
}

const titles = ["Buy milk", "Write report", "Call Ada", "Fix bug", "Plan week"];

console.log(paginate(titles));
console.log(paginate(titles, 2));
console.log(paginate(titles, undefined, 3));
console.log(paginate(titles, null, 3));
Output of node defaults.js and of the browser terminal
[ 'Buy milk', 'Write report' ]
[ 'Call Ada', 'Fix bug' ]
[ 'Buy milk', 'Write report', 'Call Ada' ]
[]

A default is used when the argument is missing or undefined. It is not used for null. In the last call, page was null, so start became (null - 1) * 3, which is -3, and the page came back empty. That matters in a backend, because JSON has null but no undefined: a client that sends "page": null skips your default. If a value may be null, use ?? on it: paginate(items, input.page ?? 1).

Destructuring in depth

In Objects and JSON you pulled properties out of an object with const { name } = user. Destructuring can do much more. Here are the forms you will read and write every day:

destructure.js
const task = {
  id: 7,
  title: "Buy milk",
  owner: { name: "Ada", email: "ada@example.com" },
};

const { title: taskTitle, priority = 3 } = task;
console.log(taskTitle, priority);

const { owner: { name: ownerName } } = task;
console.log(ownerName);

const [first, , third = "none"] = ["a", "b"];
console.log(first, third);

const [key, value] = "limit=10".split("=");
console.log(key, value);

let a = 1;
let b = 2;
[a, b] = [b, a];
console.log(a, b);
Output of node destructure.js and of the browser terminal
Buy milk 3
Ada
a none
limit 10
2 1
  • Renaming: { title: taskTitle } reads title into a variable called taskTitle. Read the colon as "into".
  • Defaults: { priority = 3 } uses 3 because the task has no priority. Like default parameters, it applies to undefined only.
  • Nested: { owner: { name: ownerName } } goes one level deeper. It crashes if owner is missing, so use it only on data you trust.
  • Arrays take items by position. An empty slot, [first, , third], skips an item.
  • Swapping two variables takes one line.

Destructuring parameters

The most useful place for destructuring is a function's parameter list. Instead of an options object you read property by property, you name the options you expect and give each a default:

params.js
function createTask({ title, done = false, tags = [] } = {}) {
  return { title, done, tags };
}

console.log(createTask({ title: "Buy milk" }));
console.log(createTask({ title: "Plan week", tags: ["work"] }));
console.log(createTask());
Output of node params.js and of the browser terminal
{ title: 'Buy milk', done: false, tags: [] }
{ title: 'Plan week', done: false, tags: [ 'work' ] }
{ title: undefined, done: false, tags: [] }

The = {} at the end is the default for the whole parameter. Without it, createTask() would crash trying to destructure undefined. You can see at a glance which options the function understands.

Spread and rest: same dots, opposite jobs

The three dots ... mean two opposite things depending on where they stand:

  • Spread expands one array or object into many items. It appears where values are used: in an array [...a], an object { ...o }, or a function call f(...args).
  • Rest collects many items into one array or object. It appears where names are created: in a parameter list function f(...args) or a destructuring pattern const [x, ...others] = list.
spread-rest.js
const priorities = [3, 1, 2];
console.log(Math.max(...priorities));

const more = [...priorities, 5];
console.log(more, priorities);

function tag(title, ...tags) {
  return `${title} [${tags.join(", ")}]`;
}
console.log(tag("Buy milk", "home", "shopping"));

const [firstTask, ...laterTasks] = ["Buy milk", "Write report", "Call Ada"];
console.log(firstTask, laterTasks);

const { id, ...changes } = { id: 7, title: "Buy oat milk", done: true };
console.log(id, changes);
Output of node spread-rest.js and of the browser terminal
3
[ 3, 1, 2, 5 ] [ 3, 1, 2 ]
Buy milk [home, shopping]
Buy milk [ 'Write report', 'Call Ada' ]
7 { title: 'Buy oat milk', done: true }

Math.max wants separate numbers, not an array, so spread expands the array into three arguments. In tag, the rest parameter collected every argument after the first into the array tags. A rest element must always come last.

NOTE

Old code used a hidden variable called arguments to reach extra arguments. A rest parameter replaces it, and it is a real array, so map and join work on it.

Enhanced object literals

You saw shorthand properties and computed properties in Objects and JSON. Together with short methods, they are called enhanced object literals. Here is an old-style response builder next to its modern form:

literals.js
function oldResponse(status, field, value) {
  var body = {};
  body[field] = value;
  return {
    status: status,
    body: body,
    describe: function () {
      return this.status + " " + JSON.stringify(this.body);
    },
  };
}

function newResponse(status, field, value) {
  return {
    status,
    body: { [field]: value },
    describe() {
      return `${this.status} ${JSON.stringify(this.body)}`;
    },
  };
}

console.log(oldResponse(201, "id", 7).describe());
console.log(newResponse(201, "id", 7).describe());
Output of node literals.js and of the browser terminal
201 {"id":7}
201 {"id":7}

Same behaviour, half the noise. status is shorthand for status: status, [field] is a computed property, and describe() { } is a short method.

var, let and const

Old code declares every variable with var. Modern code uses const by default and let when the value must change. var ignores blocks, which leads to bugs like this one:

var.js
var label = "outer";
if (true) {
  var label = "inner";
}
console.log(label);

let name = "outer";
if (true) {
  let name = "inner";
}
console.log(name);
Output of node var.js and of the browser terminal
inner
outer

The var inside the if silently overwrote the outer one. With let, the inner name exists only inside its block. Scope and how code runs explains exactly why. When you modernise code, replace every var with const, and change it to let only if the code assigns to it again.

A first look at modules

Old browser code put everything in one global space, and files shared variables by accident. Modern JavaScript splits code into modules: each file keeps its names to itself, exports what others may use, and imports what it needs. Here the formatter lives in its own file:

format.js
export function formatTitle(task) {
  return `${task.done ? "[x]" : "[ ]"} ${task.title}`;
}
main.js
import { formatTitle } from "./format.js";

console.log(formatTitle({ title: "Buy milk", done: true }));
Output of node main.js and of the browser terminal
[x] Buy milk

That is all you need for now. Modules teaches them fully: named and default exports, CommonJS require, and how to organise a project.

Build: the full refactor

Now rewrite formatTask from the start of the lesson with every tool you have seen. Keep the old version next to it, so you can compare the two on the same inputs:

refactor.js
function oldFormatTask(task, options) {
  options = options || {};
  var prefix = options.prefix || "-";
  var owner = task.owner && task.owner.name ? task.owner.name : "nobody";
  var priority = task.priority || 3;
  return prefix + " " + task.title + " (" + owner + ", priority " + priority + ")";
}

function formatTask({ title, owner, priority = 3 }, { prefix = "-" } = {}) {
  const who = owner?.name ?? "nobody";
  return `${prefix} ${title} (${who}, priority ${priority})`.trim();
}

const cases = [
  [{ title: "Buy milk", owner: { name: "Ada" }, priority: 1 }],
  [{ title: "Write report" }, { prefix: "*" }],
  [{ title: "Fix login bug", priority: 0 }, { prefix: "" }],
];

for (const args of cases) {
  console.log("old:", oldFormatTask(...args));
  console.log("new:", formatTask(...args));
}
Output of node refactor.js and of the browser terminal
old: - Buy milk (Ada, priority 1)
new: - Buy milk (Ada, priority 1)
old: * Write report (nobody, priority 3)
new: * Write report (nobody, priority 3)
old: - Fix login bug (nobody, priority 3)
new: Fix login bug (nobody, priority 0)

The new version gives the same result for the normal cases and fixes both bugs in the third one:

  • The default priority = 3 only applies to a missing value, so priority 0 survives.
  • The default prefix = "-" only applies when no prefix was given, so "" is kept. .trim() removes the leading space that an empty prefix leaves.
  • owner?.name ?? "nobody" replaces the whole && and ? : line.
  • The test loop passes each case with spread: formatTask(...args) turns the array into separate arguments.

This is how to refactor safely in real projects too: keep the old behaviour, check it on the same inputs, and change only what you meant to change. In Testing you will turn a loop like this into automatic tests.

Practice

TRY IT YOURSELF

Modernise a config reader

Rewrite this function with destructuring, default values and ??. A port of 0 must be kept (it means "pick any free port"), and a missing host must become "localhost".

config-old.js
function readConfig(config) {
  var port = config.port || 3000;
  var host = config.host || "localhost";
  return "http://" + host + ":" + port;
}

console.log(readConfig({ port: 0 }));
Output of node config-old.js and of the browser terminal
http://localhost:3000
Show a solution
config-new.js
function readConfig({ port = 3000, host = "localhost" } = {}) {
  return `http://${host}:${port}`;
}

console.log(readConfig({ port: 0 }));
console.log(readConfig({ host: "api.example.com" }));
console.log(readConfig());
Output of node config-new.js and of the browser terminal
http://localhost:0
http://api.example.com:3000
http://localhost:3000

If the config could contain null values (for example after JSON.parse), use config.port ?? 3000 instead of a default, because defaults ignore null.

TRY IT YOURSELF

Sum with rest

Write total(label, ...amounts) that returns a string like Groceries: 12, adding up all the amounts. Then call it with an existing array of amounts using spread.

Show a solution
total.js
function total(label, ...amounts) {
  const sum = amounts.reduce((acc, n) => acc + n, 0);
  return `${label}: ${sum}`;
}

console.log(total("Groceries", 4, 5, 3));

const week = [10, 20, 5];
console.log(total("Week", ...week));
console.log(total("Nothing"));
Output of node total.js and of the browser terminal
Groceries: 12
Week: 35
Nothing: 0

TRY IT YOURSELF

Safe nested read

A request may or may not have request.user.settings.language. Write languageOf(request) that returns it, or "en" when any part is missing. It must not crash on {}.

Show a solution
language.js
function languageOf(request) {
  return request.user?.settings?.language ?? "en";
}

console.log(languageOf({ user: { settings: { language: "fr" } } }));
console.log(languageOf({ user: {} }));
console.log(languageOf({}));
Output of node language.js and of the browser terminal
fr
en
en

Recap

  • Template literals `${x}` replace string gluing with +.
  • a?.b reads safely through null and undefined. a ?? b falls back only for null and undefined, so it keeps 0 and "".
  • Default parameters and destructuring defaults apply to undefined only, never to null.
  • Destructuring can rename ({ a: b }), set defaults, go into nested objects, and take arrays apart by position.
  • Spread expands (f(...list), { ...obj }). Rest collects (function f(...args), const { id, ...rest } = obj).
  • Use const by default, let when you reassign, and never var.
  • Refactor with the old and new versions side by side, checked on the same inputs.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.