Skip to content
wannacode

Text up close

Problem

A new string on every +=

You already know that title.toUpperCase(); does nothing on its own: a string is immutable, and the method returns a new string. This has a cost, and you can see it in a loop.

The online store's program builds the daily report line by line:

let report = "";
for (let i = 1; i <= 3; i++) {
report += `Order ${i}: €12.50\n`;
}
process.stdout.write(report);
Output
Order 1: €12.50
Order 2: €12.50
Order 3: €12.50

process.stdout.write prints the text without adding a new line at the end: the report already ends with \n.

By the rules of the language, each report += ... does not add to the old string. It makes a new string: the whole report so far plus the new line. If the engine really copied everything on every iteration, then near the end of 50,000 orders every iteration would copy almost a million characters. That is what happens in Java (about 2 seconds) and in Python (about 1 second).

In Node the same loop with 50,000 orders takes about 8 milliseconds. We measured it on the machine where we check the code of the lessons. V8, the engine that runs JavaScript, cheats: on += it does not copy. It remembers the two pieces side by side and glues them all at once when you need the text. This is a trick of the engine, not a rule of the language. The string is still immutable.

The idea

An array of pieces and join

JavaScript has no string that changes in place. So you collect the pieces in an array, which does change in place. At the end, join glues them into one string and copies each character only once:

const parts = [];
for (let i = 1; i <= 3; i++) {
parts.push(`Order ${i}: €12.50`);
}
const report = parts.join("\n");
console.log(report);
Output
Order 1: €12.50
Order 2: €12.50
Order 3: €12.50
  • const parts = [] is an empty array. push adds a piece to the end without touching the others.
  • parts.join("\n") makes one string from all the pieces, with the separator "\n" between them. To glue with no separator, you write parts.join("").
  • The new line between the pieces comes from the separator, so here the pieces have no \n at the end.

In Node both ways are fast: with 50,000 orders join takes about 5 milliseconds and += about 8. An array and join are the usual way when there are many pieces or you need a separator between them, and they do not depend on the engine's trick.

Here is what the rules of the language say about each approach. With += V8 puts off the copying, but the new string is still a new string:

let report = "";
for (let i = 1; i <= 3; i++) {
report += `Order ${i}: €12.50\n`;
}
const parts = [];
for (let i = 1; i <= 3; i++) {
parts.push(`Order ${i}: €12.50\n`);
}
const joined = parts.join("");
console.log(report.length, joined.length);
console.log(report === joined);
Step by step, in words
  1. report points to an empty string.
  2. The first += makes a new string with one line: 16 characters.
  3. By the rules of the language, the second += gives one more string: the old line (16 characters) and the new one. Nobody needs the old string anymore.
  4. The third gives a string with 32 old characters and the new line. Java and Python copy them every time. V8 puts off the copying: it keeps the pieces and glues them once.
  5. parts is an empty array: the pieces will go in it.
  6. push puts each piece in the array in place. The old pieces are not copied, and the array stays the same.
  7. join glues the pieces once: 48 characters. The text is the same. By the rules, += copies another 48 old characters, and join not a single one.
More precisely, if you are curious
  • V8 calls the glued pieces a "rope". When you read a character from such a string, the engine glues it into one string, once.
  • A single template literal like `Order ${i}: ${price}` is one new string. How much += copies in a loop depends on the engine, while join is predictable everywhere.

See it

Changing with slice

A string does not change in place, so every change is a new string, built from parts of the old one. Here is a card number for printing on a receipt:

let card = "4532015112830366";
card = card.slice(0, 12) + " " + card.slice(12);
card = card.slice(0, 8) + " " + card.slice(8);
card = card.slice(0, 4) + " " + card.slice(4);
console.log(card);
card = "**** **** ****" + card.slice(14);
console.log(card);
let phone = "0888-123-456";
phone = phone.slice(0, 8) + phone.slice(9);
phone = phone.slice(0, 4) + phone.slice(5);
console.log(phone);
console.log([..."stressed"].reverse().join(""));
Output
4532 0151 1283 0366
**** **** **** 0366
0888123456
desserts

card.slice(0, 12) is the characters from the start up to index 12, without it. card.slice(12) is from index 12 to the end.

You want to You write
insert x before index i s.slice(0, i) + x + s.slice(i)
replace the characters from a to b (without b) with x s.slice(0, a) + x + s.slice(b)
remove the character at index i s.slice(0, i) + s.slice(i + 1)
remove the characters from a to b (without b) s.slice(0, a) + s.slice(b)
reverse the text, back to front [...s].reverse().join("")
get the length, a character s.length, s[i]

[...s] makes an array of the characters of the string. The array has reverse(), and join("") glues it back.

The spaces in the card number go in from the back: first at 12, then at 8, then at 4. If you start at 4, the inserted space shifts everything after it, and 8 is no longer the place you want. The dashes in the phone number are removed from the back first for the same reason.

Try it

Two names, one object

What will the program print?

const note = ["Table 4"];
const copy = note;
copy.push(", 2 coffees");
console.log(note.join(""));
const text = "Table 4";
let other = text;
other += ", 2 coffees";
console.log(text);
Answer

The idea

In alphabetical order: localeCompare

=== only tells you "are they the same". To sort, you need more: which string comes before the other. That is what localeCompare tells you:

console.log("Andreev".localeCompare("Borisov", "en"));
console.log("Borisov".localeCompare("Borisov", "en"));
console.log("Andreev" < "Borisov");
console.log("ivanova" < "Ivanova");
Output
-1
0
true
false
  • a negative number: the first one comes before the second;
  • 0: they are the same;
  • a positive number: the first one comes after the second.

"en" says to compare by the rules of the English language. You can also compare strings with < and >, but then it goes by the code of each character: capital letters come before lowercase ones, so "ivanova" < "Ivanova" is false.

The sign matters, not the number itself. With it you sort a list of names, for example with the bubble sort from the lesson on arrays:

const names = ["Petrova", "Andreev", "Ivanova", "Borisov"];
for (let i = 0; i < names.length - 1; i++) {
for (let j = 0; j < names.length - 1 - i; j++) {
if (names[j].localeCompare(names[j + 1], "en") > 0) {
const tmp = names[j];
names[j] = names[j + 1];
names[j + 1] = tmp;
}
}
}
console.log(names.join(", "));
Output
Andreev, Borisov, Ivanova, Petrova

names.join(", ") glues the elements of the array with a separator between them.

More precisely, if you are curious
  • localeCompare with the "en" rules puts the lowercase letter before the capital one: "ivanova".localeCompare("Ivanova", "en") is -1. With { sensitivity: "base" } it ignores upper and lower case and gives 0.
  • With { numeric: true } it compares the numbers in the text as numbers: then "INV-10" comes after "INV-9".
  • names.sort() sorts the array without the bubble sort, but by the codes of the characters, like <. For these names the result is the same.
console.log("ivanova".localeCompare("Ivanova", "en"));
console.log("ivanova".localeCompare("Ivanova", "en", { sensitivity: "base" }));
console.log("INV-10".localeCompare("INV-9", "en", { numeric: true }));
const names = ["Petrova", "Andreev", "Ivanova", "Borisov"];
names.sort();
console.log(names.join(", "));
Output
-1
0
1
Andreev, Borisov, Ivanova, Petrova

Try it

Invoice 10 or 9?

The invoice numbers are text. Which one is earlier?

What will the program print?

const a = "INV-10";
const b = "INV-9";
console.log(a < b);
console.log(Number(a.slice(4)) < Number(b.slice(4)));
Answer

The idea

Search, cut, put together

Here is a line from a contacts file: name, email and city, separated with " ; ", with extra spaces at the ends:

const line = " Elena Koleva ; elena.koleva@company.com ; Plovdiv ";
const parts = line.trim().split(" ; ");
const email = parts[1];
const at = email.indexOf("@");
console.log(email.slice(0, at));
console.log(email.slice(at + 1));
console.log(email.indexOf(".", at));
console.log(parts.join(" | "));
console.log("=".repeat(20));
Output
elena.koleva
company.com
20
Elena Koleva | elena.koleva@company.com | Plovdiv
====================
  • trim() removes the spaces at the front and at the back, and also the "wide" spaces from other languages.
  • indexOf("@") gives the index of the first occurrence, and indexOf(".", at) starts searching only from index at on. lastIndexOf searches from the back.
  • email.slice(0, at) is from the start up to at, without it. email.slice(at + 1) is from at + 1 to the end. join is the opposite of split.
  • "=".repeat(20) repeats the text 20 times.

Empty and "blank" are not the same thing:

const empty = "";
const spaces = " ";
console.log(empty.length === 0, spaces.length === 0);
console.log(empty.trim() === "", spaces.trim() === "");
Output
true false
true true

length === 0 is true only for empty text. trim() === "" is also true when there are only spaces. To check a field someone filled in, you usually want the second one.

The idea

Numbers and text

Numbers go into text and come out of text:

const code = Number("0042");
const price = Number("2.40");
const label = String(code) + "-" + String(price);
console.log(label);
Output
42-2.4

You already know Number(). String() is the opposite: it makes text from a number or a boolean.

For a table with columns you need a width for each column. Here is a receipt from a café:

const names = ["Coffee", "Croissant", "Mineral water"];
const prices = [2.4, 3.1, 1.5];
const qty = [2, 1, 3];
let total = 0;
for (let i = 0; i < names.length; i++) {
const amount = prices[i] * qty[i];
total += amount;
console.log(`${names[i].padEnd(16)}${String(qty[i]).padStart(2)} x ${prices[i].toFixed(2).padStart(5)} = ${amount.toFixed(2).padStart(6)}`);
}
console.log("-".repeat(35));
console.log(`${"Total".padEnd(29)}€${total.toFixed(2).padStart(5)}`);
Output
Coffee 2 x 2.40 = 4.80
Croissant 1 x 3.10 = 3.10
Mineral water 3 x 1.50 = 4.50
-----------------------------------
Total €12.40
  • padEnd(16) pads the text with spaces on the right up to 16 characters: it is aligned left. padStart(5) pads on the left: it is aligned right.
  • toFixed(2) makes text from the number, with 2 digits after the point.
  • String(qty[i]) is needed because padStart is a method of a string, not of a number.
More precisely, if you are curious
  • a.concat(b) is the same as a + b. You will see it in older code.
  • padEnd and padStart do not cut longer text: it pushes the column to the right.
  • Number("2,40") does not stop the program. It gives NaN ("not a number"), and the program carries on with it. toFixed always writes a point, while toLocaleString("de") writes by the German rules, with a comma.
console.log(Number("2,40"));
console.log((2.4).toFixed(2));
console.log((2.4).toLocaleString("de", { minimumFractionDigits: 2 }));
console.log("Coffee".concat(" and ", "croissant"));
Output
NaN
2.40
2,40
Coffee and croissant

Try it

Find the bug

"Kayak" reads the same backwards, but the program says "kayak is not a palindrome". Which line has the bug?

Pick the line with the bug.

Exercise

Text you put together

Solve the tasks in your editor. Do not print prompts.

1. Palindrome. Read a line and say whether it is a palindrome, ignoring capital letters, spaces, commas and dashes. This time reverse the text with [...text].reverse().join("") instead of a loop.

Input
Never odd or even
Output
Palindrome
Input
Never odd, never even
Output
Not a palindrome

2. A receipt with columns. The first line has the number of items. Each line after it has a name, a price and a quantity, separated with ;. Print a receipt with aligned columns and the total at the end.

Input
3
Bread;1.20;2
Cheese;9.80;1
Eggs;0.35;10
Output
Bread 2 x 1.20 = 2.40
Cheese 1 x 9.80 = 9.80
Eggs 10 x 0.35 = 3.50
------------------------------------
Total 15.70

3. Words in reverse. Read a sentence and print its words in reverse order. There may be more than one space between the words, and the result must have exactly one. Put it together with an array and join.

Input
Tomorrow at 9 there is a meeting
Output
meeting a is there 9 at Tomorrow

More tasks

  • Initials. Read a full name ("Maria Ivanova Petrova") and print the initials with dots: "M. I. P." Put them together with an array and join.
  • Hidden email. From elena.koleva@company.com make e***********@company.com: the first letter, then as many asterisks as there are other characters before the @. Use indexOf and repeat.
  • A tour of strings. Create one string with quotes ("Practice text") and another with new String("Practice text"). Compare them with === and with ==, take slice(0, 8), split the first one on the space and print the parts, turn "100" into a number and print it with a template literal `Value: ${...}`, and finally add " and more" to an array with the first string and glue them with join. Predict every line before you run the program. (new String makes an object instead of a string. Real code does not use it.)
  • Sort ignoring case. Change the bubble sort from the localeCompare card to pass { sensitivity: "base" }, and try it with "ivan", "Boris" and "ana".

Test

Check yourself

1. Why is report += line not slow in Node, in a loop with 50,000 iterations?
2. What does "Borisov".localeCompare("Andreev", "en") return?
3. const a = ["12"]; const b = a; b.push("3"); What does a.join("") give?
4. parts is an array of strings. How do you make one string from it, with no separators?
5. What does (2.4).toFixed(2).padStart(6) give?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·