Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

Objects and nested data

An array reached by position is fine for a list of scores and hopeless for a person, because remembering that index 1 means the age is a job the machine should be doing.

An object stores values under keys, so a record says person.age and stays right when a field is added. If you have arrived here directly, what is assumed is arrays, for ... of, and functions that return values. This is also the lesson where recursion appears, and it appears because nested data forces it rather than as a puzzle for its own sake.

Values under keys

An object literal is written in braces, as key: value pairs:

const person = {
  name: "Ana",
  age: 34,
  email: "[email protected]"
};
console.log(person.age);
// 34

The keys are name, age and email, and the values can be anything at all: numbers, strings, arrays, other objects, functions. An object with a function as a value is how methods work, which is why "hello".toUpperCase() and [1,2].push(3) have the shape they do.

Compare the two ways of writing the same record. As an array it is ["Ana", 34, "[email protected]"], and the age is person[1]. As an object the age is person.age. The array version is shorter to type and worse in every other way: it requires the reader to know the convention, it breaks silently everywhere if a field is inserted at the front, and it makes a mistyped index into a wrong value rather than an obvious error. The general rule is that positions are for things of the same kind, where order carries meaning, and keys are for things of different kinds, where the name carries it.

Empty objects are useful in the same way empty arrays are. {} is the natural starting accumulator when you are building a set of counts, which is a pattern this lesson gets to shortly.

Dot, bracket, and the missing key

There are two ways to reach a value, and they are not interchangeable:

console.log(person.age);       // 34
console.log(person["age"]);    // 34

const field = "age";
console.log(person[field]);    // 34
console.log(person.field);     // undefined

Dot notation takes a literal key written into the source. Bracket notation takes an expression that evaluates to a key, so it is what you use when the key is in a variable, comes from user input, or is being built. person.field looks for a key literally called "field", does not find one, and gives undefined. That confusion is worth a minute now because it costs an hour later.

Reading a key that does not exist gives undefined rather than an error, exactly as reading past the end of an array does, and with the same consequence: the failure surfaces later and somewhere else. Reading a key of undefined does raise an error, and this is the most common way for a JavaScript program to stop:

console.log(person.address.city);
// TypeError: undefined is not an object (evaluating 'person.address.city')

person.address is undefined, and undefined has no properties, so the second dot fails. The exact wording varies between engines, but the shape is always the same and it names the property it was trying to read, which is your first clue about where to look.

Three defences exist, in increasing order of preference. Test first, if (person.address), which the short circuiting of && makes compact. Use optional chaining, person.address?.city, which evaluates to undefined when the left side is null or undefined instead of throwing. Or fix the data, so that a person always has an address object even when it is empty. The third is best where you control the shape, because it removes the question rather than answering it repeatedly.

Adding and changing are the same operation, person.city = "Lisbon", and delete person.email removes a key entirely. To ask whether a key is present, "email" in person gives a boolean and is more precise than checking the value against undefined, since a key can be present with the value undefined.

Finally, three functions turn an object back into arrays so the previous lesson's methods apply: Object.keys(person) gives ["name", "age", "email"], Object.values gives the values, and Object.entries gives an array of [key, value] pairs. One quirk to know: object keys are always strings. Writing counts[1] = "x" stores it under the key "1", and Object.keys returns ["1"].

Example. What does each line print?

const config = { host: "localhost", port: 8080 };
const key = "port";
console.log(config.port);
console.log(config[key]);
console.log(config.key);
console.log(config.timeout);

8080, then 8080, then undefined, then undefined. The third is the dot-versus-bracket confusion: config.key asks for a key spelled key, which does not exist. The fourth is an ordinary missing key. Both give undefined with no error, which is why a program that reads configuration usually checks for the keys it needs rather than trusting them.

Now you. Given const order = { id: 7, customer: { name: "Ben" } };, what do order.customer.name, order.customer.email and order.payment.method each give?

Answer

"Ben", then undefined, then a TypeError. The second reads a missing key of an object that exists, which is merely absent. The third reads a key of order.payment, which is undefined, and undefined has no properties, so the program stops. order.payment?.method would give undefined instead of throwing.

Arrays of objects

Almost all real data is an array of objects: rows from a database, items in a basket, results from a request. It is the shape worth being fluent in, because everything from the previous lesson applies to it directly.

const orders = [
  { id: 1, customer: "Ana", amount: 40, paid: true },
  { id: 2, customer: "Ben", amount: 25, paid: false },
  { id: 3, customer: "Ana", amount: 60, paid: true }
];

const paidTotal = orders
  .filter(o => o.paid)
  .map(o => o.amount)
  .reduce((sum, a) => sum + a, 0);
console.log(paidTotal);
// 100

The pipeline is the one from the previous lesson, and the only new thing is that each element is a record with named fields, which makes each step readable without a comment.

The pattern that turns up constantly and is worth memorising is grouping, or counting, into an object:

const counts = {};
for (const o of orders) {
  counts[o.customer] = (counts[o.customer] ?? 0) + 1;
}
console.log(counts);
// { Ana: 2, Ben: 1 }

The ?? 0 supplies the starting value the first time a customer is seen. Using || there would work for a count and break for a total, because a legitimate running total of 0 is falsy and would be replaced. This is the case the nullish operator exists for.

Example. From orders, produce an object mapping each customer to their total amount, and check it.

const totals = {};
for (const o of orders) {
  totals[o.customer] = (totals[o.customer] ?? 0) + o.amount;
}
console.log(totals);
// { Ana: 100, Ben: 25 }

Ana has orders of 40 and 60, so 100; Ben has one of 25. Note that this counts unpaid orders too, since no filter was applied; whether that is right depends on the question, and it is the kind of thing worth stating in the function's name, totalsByCustomer against paidTotalsByCustomer.

Now you. Using the same orders, produce an object mapping each customer to the number of their unpaid orders, and say what it gives.

Answer
const unpaid = {};
for (const o of orders.filter(o => !o.paid)) {
  unpaid[o.customer] = (unpaid[o.customer] ?? 0) + 1;
}
console.log(unpaid);
// { Ben: 1 }

Only order 2 is unpaid, so Ben has 1 and Ana does not appear at all. That absence is worth noticing: unpaid.Ana is undefined, not 0, so any code reading these counts has to handle a missing key, which ?? 0 again does.

JSON, and data as text

Data that leaves a program has to become text, because that is what a file holds and what a network carries. JSON, JavaScript Object Notation, is the format nearly everything uses, and it is deliberately a small subset of what JavaScript can write: objects, arrays, strings, numbers, true, false and null. Keys must be in double quotes and trailing commas are forbidden.

JSON.stringify(value) turns a value into text and JSON.parse(text) turns it back. The round trip is not lossless, and the losses are worth knowing before they surprise you:

console.log(JSON.stringify({ a: 1, b: undefined, c: [1, 2] }));
// {"a":1,"c":[1,2]}

undefined is dropped, as are functions. A Date becomes a string and does not become a Date again on the way back. NaN and Infinity become null. And JSON.parse throws on malformed input rather than returning null, which is a good default and means anything reading a file or a response needs the error handling of the next lesson.

When the keys are data

An object works well when the keys are field names you wrote yourself. It works less well when the keys are data: user identifiers, arbitrary words, values from a file. Three reasons, in order of how often they bite.

Keys become strings, so the number 1 and the string "1" collide, and any key that is not a string is silently converted. Every object inherits a handful of properties from the language, so a lookup for the key "constructor" finds something even in an object you have just created and filled yourself. And there is no cheap way to ask how many keys there are without building an array of them.

Map fixes all three. It is a separate structure with its own methods, keys of any type including objects, a size property, and no inherited keys:

const seen = new Map();
seen.set("ana", 3);
seen.set(2, "two");
console.log(seen.get(2), seen.size, seen.has("ana"));
// two 2 true

The working rule: plain objects for records with known fields, Map for lookup tables whose keys come from data. Set, its sibling, holds unique values with fast membership tests, which is the right structure for "have I seen this before" and much faster than includes on a growing array.

Nesting, and the walk that needs recursion

Objects hold objects, which hold arrays of objects. A folder contains files and folders; a comment has replies which have replies; a company has departments which have teams. Such a shape has no fixed depth, and that is what defeats every loop written so far. A loop with one index visits one level. Two nested loops visit two. There is no number of nested loops that visits an unknown number of levels, for the same reason there was no number of if statements that could check an unknown number of divisors.

The way out is for a function to call itself. A recursive function has two parts, and getting either wrong is the whole of the difficulty. The base case is an input small enough to answer directly, with no further call. The recursive case breaks the problem into smaller pieces of the same shape, calls itself on each, and combines the results. Each call must move strictly towards the base case, which is the same variant argument that made a loop terminate.

const tree = {
  name: "root", size: 0, children: [
    { name: "a", size: 3, children: [] },
    { name: "b", size: 0, children: [
      { name: "c", size: 5, children: [] },
      { name: "d", size: 7, children: [] }
    ] }
  ]
};

function totalSize(node) {
  let sum = node.size;
  for (const child of node.children) {
    sum = sum + totalSize(child);
  }
  return sum;
}
console.log(totalSize(tree));
// 15

The base case is hiding in plain sight: a node with no children has an empty children array, so the loop body never runs and the function returns node.size without calling itself. That is the shape to look for, since an explicit if is often unnecessary when the empty collection already stops the descent. The answer checks by hand: 0+3+(0+5+7)=15.

Trust the recursive call. The commonest reason people find recursion hard is that they try to trace every level in their head, which is exactly as hopeless as tracing a loop over a million elements. The argument that works is the one from loop invariants, run inductively: assume totalSize(child) returns the correct total for that child's whole subtree, and then check that the line combining those returns is right. Base case correct, plus combining step correct, gives correct for every depth.

The limit is real and worth stating. Each call occupies space on the call stack, and a recursion too deep for it fails with a stack overflow, which in Node is typically somewhere around ten thousand frames deep. That is far more than any folder tree and far less than a list of a million items, so recursion is the right tool for shapes that branch and the wrong one for long flat sequences. The following course on algorithms takes this much further.

Example. Write a function that counts the leaves of such a tree, where a leaf is a node with no children, and check it against tree.

function countLeaves(node) {
  if (node.children.length === 0) return 1;
  let n = 0;
  for (const child of node.children) {
    n = n + countLeaves(child);
  }
  return n;
}
console.log(countLeaves(tree));
// 3

Here the base case has to be explicit, because a leaf contributes 1 rather than 0 and the empty loop would return the wrong value. The leaves are a, c and d, so 3 is right, and root and b contribute nothing themselves.

Now you. Write a function returning the depth of the tree, counting the root as depth 1. What does it give for tree?

Answer
function depth(node) {
  if (node.children.length === 0) return 1;
  let deepest = 0;
  for (const child of node.children) {
    const d = depth(child);
    if (d > deepest) deepest = d;
  }
  return 1 + deepest;
}
console.log(depth(tree));
// 3

A leaf has depth 1, and any other node is one deeper than its deepest child. For tree: a is 1, c and d are 1, so b is 2, and the root is 3. The deepest accumulator is the largest function from the arrays lesson, starting at 0 rather than at the first element because the recursive case is only reached when there is at least one child.

Two names, one value

Objects and arrays complete the toolkit: values, decisions, loops, functions, sequences and records, and enough to write a real program.

They also introduce something not yet said out loud, and it is the reason the rest of this course is about being wrong. When an array or an object is assigned to a second name, or passed to a function, the thing copied is not the data. Two names end up reaching the same value, and a change made through one is visible through the other. That produces bugs which cannot be found by reading the line that misbehaves, because the line that misbehaves is not the line that caused it. That is next.