Array methods
Problem
A report in a loop
Orders now have a city too:
class Order { constructor(customer, city, total) { this.customer = customer; this.city = city; this.total = total; }}const orders = [ new Order("Maria", "Sofia", 75.5), new Order("Georgi", "Varna", 12.9), new Order("Elena", "Sofia", 40), new Order("Dimitar", "Plovdiv", 120), new Order("Ana", "Varna", 64),];You want the customers with orders of at least €50, in alphabetical order:
const result = [];for (const o of orders) { if (o.total >= 50) { result.push(o.customer); }}result.sort();console.log(result);[ 'Ana', 'Dimitar', 'Maria' ]Eight lines, and the question is just one. The code says how: make an empty list, loop, check, add, sort. To understand what it does, you have to run it in your head. Add "only the first three" or "no duplicates", and the loop will grow.
The idea
A chain of steps
The same report, written as steps:
const result = orders .filter((o) => o.total >= 50) .map((o) => o.customer) .toSorted();console.log(result);[ 'Ana', 'Dimitar', 'Maria' ]You read it from top to bottom: take the orders, keep the ones of at least €50, take the customer, sort. Each step is an array method and takes an arrow function from the previous lesson:
filter(condition)keeps only the elements for which the condition is true;map(function)turns each element into another one: it makes a string from anOrder;toSorted()sorts alphabetically, andtoSorted(compare function)by your rule.
Each method returns a new array, and the next one is called on it. That is why the steps line up in a chain. The array orders does not change.
More precisely, if you are curious
- The old
sortsorts the array itself and returns it.toSortedis the newer one and returns a new array. You will see why the difference matters in the card "Find the bug". - Each step goes through the whole array before the next one starts. A conveyor belt that the elements go along one at a time is what an iterator gives you. The next card is about it.
See it
What gets through the filter
values() gives. The iterator has the same filter and map, but it is a conveyor belt: it gives the values one at a time, when something asks for them. toArray() collects them into an array.const prices = [12, 75, 40, 130, 58];const labels = prices .values() .filter((p) => p >= 50) .map((p) => `€${p}`) .toArray();console.log(labels);Step by step, in words
- Five prices. values() gives an iterator: a path that nothing has gone through yet.
- 12 does not pass p >= 50 and drops out. It never gets to map.
- 75 passes filter, map turns it into the text "€75", toArray() collects it.
- 40 drops out too. Each element goes the whole way before the next one starts.
- 130 and 58 pass. The array prices does not change.
- The result is a new array with three strings.
The five prices go one at a time. 12 gets to filter and drops out. 75 passes, map turns it into the text "€75", and toArray() collects it. Then 40 drops out, and 130 and 58 pass.
Each element goes the whole way before the next one starts. And nothing starts until toArray() asks for the values.
More precisely, if you are curious
This is called lazy evaluation. The iterator methods only record what needs to be done. See it with a print in every step:
const check = (p) => { console.log("filter " + p); return p >= 50;};const double = (p) => { console.log("map " + p); return p * 2;};
console.log([12, 75, 40].filter(check).map(double));
const stream = [12, 75, 40].values().filter(check).map(double);console.log("The iterator is ready, nothing has gone through it yet.");console.log(stream.toArray());filter 12filter 75filter 40map 75[ 150 ]The iterator is ready, nothing has gone through it yet.filter 12filter 75map 75filter 40[ 150 ]- The array (the first
console.log) does everything right away and step by step:filtergoes through all three prices, and only then doesmapgo through the one that is left. - The iterator is ready before a single element has gone through it. Then
map 75comes right afterfilter 75: the elements do not wait for all of them to go throughfilter. - That is why you can take just the first few values from the iterator with
take, without checking the rest:
const check = (p) => { console.log("filter " + p); return p >= 50;};
console.log([75, 130, 58, 12, 99].values().filter(check).take(2).toArray());filter 75filter 130[ 75, 130 ]For everyday tasks the array is enough and is easier to read. The iterator is for a lot of data, or when you need only the first few.
The idea
Sum, count, average
The result can be a number instead of a list. The sum of the even numbers:
const numbers = [1, 2, 3, 4, 5];const sum = numbers.filter((n) => n % 2 === 0).reduce((total, n) => total + n, 0);console.log(`Sum of the even numbers: ${sum}`);Sum of the even numbers: 6reduce(function, start) reduces the array to one value. It starts from 0, and the function gets the sum so far and the next element and returns the new sum.The same for the orders:
const big = orders.filter((o) => o.total >= 50).length;const revenue = orders.reduce((sum, o) => sum + o.total, 0);const average = orders.length > 0 ? revenue / orders.length : 0;const maxTotal = Math.max(...orders.map((o) => o.total));
console.log(`Orders over €50: ${big}`);console.log(`Revenue: €${revenue.toFixed(2)}`);console.log(`Average: €${average.toFixed(2)}`);console.log(`Largest: €${maxTotal.toFixed(2)}`);Orders over €50: 3Revenue: €312.40Average: €62.48Largest: €120.00- The
lengthof the filtered array is the count. - The average is the sum divided by the count. If there are no orders,
0 / 0isNaN("not a number"), hence the checkorders.length > 0. Math.max(...array)gives the largest number....spreads the array into separate parameters:Math.max(75.5, 12.9, ...). If the array is empty, the result is-Infinity. What to do with a result that may be missing is the next idea.
More precisely, if you are curious
With an empty array:
const prices = [];console.log(prices.reduce((sum, p) => sum + p, 0));console.log(Math.max(...prices));console.log(0 / prices.length);0-InfinityNaNWithout a starting value, reduce on an empty array stops with TypeError: Reduce of empty array with no initial value. So always write the starting value.
The idea
Sort, cut, no duplicates
const ratings = [5, 3, 5, 4, 2, 4, 5];console.log([...new Set(ratings)].toSorted((a, b) => a - b));
const top = orders .toSorted((a, b) => b.total - a.total) .slice(0, 3) .map((o) => o.customer);console.log(top);
Array.from({ length: 3 }, (_, i) => i + 1).forEach((i) => console.log(`Table ${i}`));[ 2, 3, 4, 5 ][ 'Dimitar', 'Maria', 'Ana' ]Table 1Table 2Table 3new Set(ratings)removes the duplicates: in a set each value is there once.[...set]turns it back into an array.toSorted((a, b) => b.total - a.total)sorts from the largest order, with a compare function from the previous lesson.slice(0, 3)takes the elements from index 0 up to index 3, without it: the first three. Together with the sort, that is a "top 3".- JavaScript has no
range.Array.from({ length: 3 }, (_, i) => i + 1)makes an array with 3 elements, calling the function for each indexi: 1, 2, 3._is the name of a parameter we do not need.
More precisely, if you are curious
A Set keeps the values in the order they were added, so without sorting you get the order of first appearance:
const ratings = [5, 3, 5, 4, 2, 4, 5];console.log([...new Set(ratings)]);[ 5, 3, 4, 2 ]Try it
What gets to the end?
What will the program print?
const drinks = ["coffee", "tea", "coffee", "water", "tea", "juice"];const result = [...new Set(drinks)] .toSorted() .slice(0, 3) .map((d) => d.toUpperCase());console.log(result);new Set leaves coffee, tea, water, juice. toSorted() sorts them alphabetically: coffee, juice, tea, water. slice(0, 3) keeps the first three, and map makes them uppercase.The idea
undefined: a result that may be missing
Which order is the largest? If the array is empty, there is no answer. Then the result is undefined: "no value". But the caller has to remember to check. If they forget, the program stops with a TypeError somewhere later.
Java has Optional for this: a box that either holds a value or is empty. JavaScript has no such box, but it has two operators that make the check short:
const biggest = orders.toSorted((a, b) => b.total - a.total)[0];if (biggest !== undefined) { console.log(`Largest: ${biggest.customer}`);}
const huge = orders.find((o) => o.total > 500);console.log(huge !== undefined);console.log(huge?.customer ?? "no such order");Largest: Dimitarfalseno such order[0]of an empty array isundefined.find(condition)gives the first element for which the condition is true, orundefined.huge?.customer: ifhugeisundefinedornull, the whole expression isundefined, instead of the program stopping. This is called optional chaining.x ?? ygivesyifxisundefinedornull, andxotherwise.
Your own functions usually return null when they have no answer: "missing on purpose". ?? works with it too:
function nickname(user) { // Returns the nickname, or null if the person has none. return user === "maria" ? "Mimi" : null;}const maria = nickname("maria");if (maria !== null) { console.log(`Nickname: ${maria}`);}
const georgi = nickname("georgi");console.log(georgi ?? "no nickname");Nickname: Mimino nicknameMore precisely, if you are curious
- Without
?.:
const huge = orders.find((o) => o.total > 500);console.log(huge.customer);TypeError: Cannot read properties of undefined (reading 'customer')x || yalso gives a fallback, but it treats an empty string and0as missing too.??does not (the third line is an empty string):
console.log(null ?? "no nickname");console.log("" || "no nickname");console.log("" ?? "no nickname");console.log(0 ?? 5);no nicknameno nickname
0someandeveryanswer withtrueorfalsewhether any or all of the elements match the condition:
console.log(orders.some((o) => o.total > 100));console.log(orders.every((o) => o.total >= 10));truetrueTry it
Find the bug
Pick the line with the bug.
sort does not make a new array. It sorts prices itself and returns it, so sorted and prices are one and the same array:
Most expensive: 130First in the list: 130The fix is toSorted on line 2. It returns a new sorted array, and prices stays 12, 75, 40, 130.
The iterator from values(), on the other hand, goes through only once, like a stream in Java. The second toArray() gives an empty array:
const prices = [12, 75, 40, 130];const expensive = prices.values().filter((p) => p >= 50);console.log(expensive.toArray());console.log(expensive.toArray());[ 75, 130 ][]The idea
Grouping and joining
How many orders are there from each city, and what is the revenue?
const byCity = Map.groupBy(orders, (o) => o.city);const cities = [...byCity.keys()].toSorted();
const countByCity = Object.fromEntries(cities.map((city) => [city, byCity.get(city).length]));console.log(countByCity);
for (const city of cities) { const sum = byCity.get(city).reduce((total, o) => total + o.total, 0); console.log(`${city}: €${sum.toFixed(2)}`);}
const customers = orders .map((o) => o.customer) .toSorted() .join(", ");console.log(customers);{ Plovdiv: 1, Sofia: 2, Varna: 2 }Plovdiv: €120.00Sofia: €115.50Varna: €76.90Ana, Dimitar, Elena, Georgi, MariaMap.groupBy(array, function)gives aMap: for each city, an array of its orders.[...byCity.keys()]makes an array of the keys, andtoSorted()sorts them alphabetically.Object.fromEntries(pairs)makes an object from[key, value]pairs. Here the key is the city, and the value is the number of its orders (length).join(", ")joins the strings into one, with a comma between them.
A Map keeps the keys in the order they were added: Sofia, Varna, Plovdiv. That is why the cities are sorted explicitly.
More precisely, if you are curious
- For each city, an array of the names. Here the cities are in the order they were added:
const namesByCity = new Map();for (const [city, list] of Map.groupBy(orders, (o) => o.city)) { namesByCity.set(city, list.map((o) => o.customer));}console.log(namesByCity);Map(3) { 'Sofia' => [ 'Maria', 'Elena' ], 'Varna' => [ 'Georgi', 'Ana' ], 'Plovdiv' => [ 'Dimitar' ]}- If a key repeats in the pairs for
Object.fromEntries, the last value wins, with no error (in JavatoMapthrows an exception):
const lastByCity = Object.fromEntries(orders.map((o) => [o.city, o.customer]));console.log(lastByCity);{ Sofia: 'Elena', Varna: 'Ana', Plovdiv: 'Dimitar' }Exercise
Reports with array methods
ask, as before.1. Sales by category. A class Sale with a product, a category, a quantity and a unit price, and a method revenue() (quantity times price). Group by category with Map.groupBy, add up each group with reduce, print the categories alphabetically and the total revenue. The sales: coffee beans, drinks, 12 at €18.50; tea, drinks, 30 at €4.20; chocolate, sweets, 40 at €2.80; biscuits, sweets, 25 at €3.10; honey, food, 8 at €12; coffee beans again, 5 at €18.50.
Drinks: €440.50Food: €96.00Sweets: €189.50Total: €726.002. Top 3 products. With the same sales, find the three products with the highest revenue. The coffee is in two sales, so first group by product and add up, then sort the map's entries by value. Hint: [...byProduct].toSorted((a, b) => b[1] - a[1]).slice(0, 3): each element of a Map is a pair [product, revenue].
Coffee beans: €314.50Tea: €126.00Chocolate: €112.003. Average grade. Read one line of grades separated by spaces. Print the average, the highest and how many are excellent (5.50 and up). Hint: (await ask("")).split(" ").map(Number).
4.50 5.75 6.00 3.00 5.50Average: 4.95Highest: 6.00Excellent: 34. The words in a text. Read a line of text and print the words in it with no duplicates, in alphabetical order, in lowercase, separated by commas. Split on everything that is not a letter: split(/[^\p{L}]+/u), and remove the empty strings.
Coffee with milk, coffee without sugar. Tea with honey!coffee, honey, milk, sugar, tea, with, withoutMore tasks
- The longest word. With
reduce, find the longest word in a sentence. What will happen for an empty line without a starting value? - Average by class. Students with a name, a class and a grade: group them with
Map.groupByand print the average grade for each class. - Big and small orders. With
some, check whether there is an order over €1000, and witheverywhether all of them are at least €10.
Test