Learn JavaScript

Lesson 3 of 9 · Strings and Template Literals

Module 3 · Strings and Template Literals

Full Programs: Strings at Work

FreeReading

In this lesson

  • Write six complete programs on the judge's starter that take text apart and build it again, from a word counter to a printed receipt.
  • Explain why split(",") breaks on a comma inside quotes, and why the word counter needs filter(Boolean) and the palindrome check needs join("").
  • Choose the starter's lines variant when the input is text with spaces, and line up output with template literals, padEnd and padStart.

Amara runs a small book blog with a shop attached. Every post needs a web address made from its title, the editor shows a word count, and every order prints a receipt. Under each feature sits a small program that takes a string apart and builds a new one. This lesson writes six of them, each about thirty lines, from a word counter to the receipt.

The starter, in its lines variant

Every program here reads its input with the fixed starter from Module 1. Most of them read text with spaces in it, such as a title or a sentence. The token starter would cut that text into words and lose the spaces, so these programs use the lines variant. Exactly two lines change.

The lines variant, and the building blocks of this lesson

const lines = input.split("\n");
const next = () => lines[at++];

text.split(" ").filter(Boolean)
text.split("").reverse().join("")
text.charCodeAt(i)        String.fromCharCode(code)
`${value}`.padStart(width)   name.padEnd(width)
  • The first two lines replace the token split and the token reader, so next() returns one whole line, an empty one included.
  • split(" ").filter(Boolean) cuts at spaces and drops the empty pieces.
  • split(""), reverse() and join("") cut text into characters, flip them and glue them back.
  • charCodeAt(i) gives a character's code; String.fromCharCode(code) goes back.
  • padStart and padEnd add spaces on the left or the right up to width characters.

From here on, every output line is a template literal, as lesson 02 showed, with no + chains.

Counting words

A word, for an editor, is a run of characters with no space in it. Splitting at every space almost works, but two spaces in a row leave an empty piece between them, and so does an empty line. filter(Boolean) drops those pieces, because "" is falsy.

Example 1: the editor's word count

New here: the lines variant, and split(" ").filter(Boolean) on a whole line. The program reads n lines of a draft and reports the total number of words and the longest one. The inner loop is the for-of loop, which Module 4 teaches: it visits each word of the array in turn.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

const n = nextInt();
let total = 0;
let longest = "";
for (let i = 0; i < n; i++) {
  const words = next().split(" ").filter(Boolean);
  total += words.length;
  for (const word of words) {
    if (word.length > longest.length) {
      longest = word;
    }
  }
}
out.push(`lines: ${n}`);
out.push(`words: ${total}`);
out.push(`longest: ${longest} (${longest.length} characters)`);

console.log(out.join("\n"));
lines: 4
words: 8
longest: Template (8 characters)

That output is for the input 4, then four lines. They are a sentence with two spaces in the middle, an empty line, a line that starts with three spaces, and save time. The empty line counted 0 words and the leading spaces counted none. Template and literals both have 8 characters; the > keeps the first one found.

Run in Compiler

A palindrome, two ways

A palindrome reads the same backwards, such as noon. A phrase counts when you ignore its capitals and its spaces. There are two ways to check: reverse the text with methods and compare, or walk in from both ends and compare pairs.

Example 2: one phrase, checked twice

New here: split(""), reverse() and join("") as one chain, and the loop that compares position i with position length - 1 - i.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

const phrase = next();
const clean = phrase.toLowerCase().split(" ").join("");
const reversed = clean.split("").reverse().join("");

let same = true;
for (let i = 0; i < clean.length / 2; i++) {
  if (clean[i] !== clean[clean.length - 1 - i]) {
    same = false;
  }
}

out.push(`cleaned:    ${clean}`);
out.push(`reversed:   ${reversed}`);
out.push(`by reverse: ${clean === reversed ? "yes" : "no"}`);
out.push(`by loop:    ${same ? "yes" : "no"}`);

console.log(out.join("\n"));
cleaned:    neveroddoreven
reversed:   neveroddoreven
by reverse: yes
by loop:    yes

That output is for the input Never odd or even. split(" ").join("") removes the spaces: cut at each space, glue with nothing. With the input Kenji was here, both checks say no. The method chain is shorter to read. The loop builds no second string and only walks to the middle, an idea lesson 06 turns into the two-pointer pattern.

Run in Compiler

A Caesar shift with character codes

A Caesar shift moves every letter a fixed number of places along the alphabet: with a shift of 3, a becomes d. The trick is to work with numbers. Lesson 02's code table puts A to Z at codes 65 to 90 and a to z at 97 to 122. They are the same numbers as the C track's ASCII table.

So code - 97 turns a small letter into its place in the alphabet, 0 to 25. Add the shift, take % 26 so 26 wraps to 0, and add 97 back. A shift of 13 is called ROT13, and it undoes itself.

Example 3: ROT13, the spoiler hider

New here: charCodeAt and String.fromCharCode in a loop over every character, with capitals and small letters handled separately and everything else copied unchanged.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

const n = nextInt();
for (let i = 0; i < n; i++) {
  const text = next();
  let result = "";
  for (let j = 0; j < text.length; j++) {
    const code = text.charCodeAt(j);
    if (code >= 65 && code <= 90) {
      result += String.fromCharCode((code - 65 + 13) % 26 + 65);
    } else if (code >= 97 && code <= 122) {
      result += String.fromCharCode((code - 97 + 13) % 26 + 97);
    } else {
      result += text[j];
    }
  }
  out.push(result);
}

console.log(out.join("\n"));
Jul qvq Obo pebff gur ebnq?
To get to the other side!

That output is for the input 2, then a joke and its answer in ROT13. One program hid the question and revealed the answer. The spaces and the ? passed through, because their codes are outside both ranges.

A shift of 13 never goes below 0. A negative shift can, and Module 1's rule for % predicts what goes wrong. The brain teaser below asks you to find it, and Exercise 2 needs the fix.

Run in Compiler

Splitting a simple CSV line

CSV, short for comma-separated values, is the plain-text table every spreadsheet can export: one record per line, fields separated by commas. For simple lines, split(",") is the whole parser. trim() then removes the spaces people type after a comma.

Example 4: a member list, and where it breaks

New here: split(",") plus trim() on every field, and a record that this simple parser gets wrong.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

const n = nextInt();
for (let i = 0; i < n; i++) {
  const parts = next().split(",");
  const fields = [];
  for (const part of parts) {
    fields.push(part.trim());
  }
  out.push(`${fields.length} fields: ${fields.join(" | ")}`);
}

console.log(out.join("\n"));
3 fields: Zara | 14 | Dhaka
3 fields: Bob | 15 | Sylhet
4 fields: "Maria" | 15 | "Flat 3 | Green Road"

That output is for the input 3, then the three records shown. The first two are fine, extra spaces and all. The third is wrong. CSV puts a field in double quotes when it holds a comma, and split knows nothing about quotes, so Maria's address became two fields. Lesson 04 fixes it with a loop.

Run in Compiler

A slug maker

A slug is the part of a web address made from a title: Hello, World! 2026 becomes hello-world-2026. It keeps only small letters and digits, so it is safe in any address. Every other run of characters becomes one hyphen, and no hyphen sits at either end.

The slug maker's pipeline From a title to a slug, one step at a time "Hello, World! 2026" toLowerCase() "hello, world! 2026" keep a-z and 0-9; any other character ends a word "hello" "world" "2026" words join("-") "hello-world-2026" ", " and "! " are runs of other characters, so each one only ends a word. join puts a hyphen between words, never before the first or after the last.
Example 5: one title, every stage printed

New here: a character loop that collects runs of letters and digits into words, then join("-"). The test ch >= "a" && ch <= "z" works because < on strings compares codes, as lesson 02 showed.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

const title = next();
const lower = title.toLowerCase();

const words = [];
let word = "";
for (const ch of lower) {
  if ((ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")) {
    word += ch;
  } else if (word !== "") {
    words.push(word);
    word = "";
  }
}
if (word !== "") {
  words.push(word);
}
const slug = words.join("-");

out.push(`title:     ${title}`);
out.push(`lowercase: ${lower}`);
out.push(`words:     ${words.join(" ")}`);
out.push(`slug:      ${slug}`);

console.log(out.join("\n"));
title:     Amara's Top 10 Books (2026)!
lowercase: amara's top 10 books (2026)!
words:     amara s top 10 books 2026
slug:      amara-s-top-10-books-2026

That output is for the input Amara's Top 10 Books (2026)!. The last if saves the final word, which no character after it ends. Look at amara-s: our rule turned the apostrophe into a break. Many slug makers delete an apostrophe instead, which is one more rule, not a new idea.

Run in Compiler

A receipt that lines up

A receipt is a table in plain text, and each column needs a fixed width. padEnd(16) fills a name with spaces up to 16 characters, and padStart(8) pushes a number to the right edge of 8. Both are string methods, so a number becomes text first.

Example 6: Amara's receipt

New here: indexOf with a start position to find the second space, slice to cut the line into three parts, a long name shortened, and fixed-width columns.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

const WIDTH = 34;
const n = nextInt();
let total = 0;
out.push("Amara's Book Corner".padStart(26));
out.push("-".repeat(WIDTH));
for (let i = 0; i < n; i++) {
  const line = next();
  const a = line.indexOf(" ");
  const b = line.indexOf(" ", a + 1);
  const qty = Number(line.slice(0, a));
  let name = line.slice(b + 1);
  const price = Number(line.slice(a + 1, b));
  if (name.length > 16) {
    name = name.slice(0, 15) + ".";
  }
  const cost = qty * price;
  total += cost;
  out.push(`${name.padEnd(16)}${`${qty} x ${price}`.padStart(10)}${String(cost).padStart(8)}`);
}
out.push("-".repeat(WIDTH));
out.push(`${"TOTAL (Tk)".padEnd(26)}${String(total).padStart(8)}`);

console.log(out.join("\n"));
       Amara's Book Corner
----------------------------------
Notebook A5        2 x 120     240
Fountain pen       1 x 450     450
Encyclopedia of.  1 x 1250    1250
----------------------------------
TOTAL (Tk)                    1940

That output is for the input 3, then three items, each a quantity, a price in taka and a name. The name may hold spaces, so it is everything after the second space. `${qty} x ${price}` is a template inside another one's ${}: it builds the middle column, then pads it. The 26-character name was cut to 15 characters and a dot, so the columns still line up.

Run in Compiler

Where this is used

  • WordPress. Its sanitize_title() function, written in PHP, turns a post title into a slug. By default it calls sanitize_title_with_dashes(), so "My Excellent Sentence" becomes my-excellent-sentence, Example 5's idea in another language.
  • slugify on npm. This JavaScript package turns slugify("some string") into some-string. It keeps capitals unless you pass lower: true, and it maps some symbols to words, such as ♥ to love.
  • Papa Parse. A CSV parser for JavaScript that says it is correct according to RFC 4180, the CSV standard. It handles the quoted commas and line breaks that Example 4 cannot.
  • ROT13 on Usenet and in Python. In the early 1980s, people on Usenet newsgroups used ROT13 to hide puzzle answers and spoilers. Python's codecs module still ships it as the rot_13 text transform.

Common mistakes

1. Counting words with split(" ") alone.

const line = "Amara writes  every day";
console.log(line.split(" "));
console.log(line.split(" ").length);
console.log(line.split(" ").filter(Boolean).length);
[ 'Amara', 'writes', '', 'every', 'day' ]
5
4

No error: the two spaces leave an empty string between them, and it is counted as a word. Add .filter(Boolean). You will miss it because your own test sentence had single spaces, and real text rarely does.

2. Comparing the reversed array with the string.

const word = "anna";
const back = word.split("").reverse();
console.log(back);
console.log(back === word);
console.log(back.join("") === word);
[ 'a', 'n', 'n', 'a' ]
false
true

reverse() gives back an array, and an array is never === to a string. So every word looks like it is not a palindrome. End the chain with join(""). You will drop it because split and reverse already look like the whole job.

3. Calling padStart on a number.

const cost = 240;
console.log("cost:", cost);
console.log("Notebook".padEnd(12) + cost.padStart(6));
cost: 240
TypeError: cost.padStart is not a function

The Playground's verdict is Runtime error, and the line printed before the stop stays. padStart is a string method, and cost is a number. Write String(cost).padStart(6), or pad a template literal. You will forget because the number looks like text on the screen.

Brain teaser

Bob writes the Caesar formula from Example 3 with a shift k: (code - 97 + k) % 26 + 97. He tests it on z (code 122) with k = 1 and on a (code 97) with k = -1.

console.log(String.fromCharCode((122 - 97 + 1) % 26 + 97));
console.log(String.fromCharCode((97 - 97 - 1) % 26 + 97));

The first line prints a, which is right. What does the second line print instead of z, and why does the same formula work one way and not the other?

Work out -1 % 26 with Module 1's rule for the sign of a remainder. Then look up the code you get in lesson 02's table.

Exercise 1Easy

Zara is building a word game for her class. Players type single words, and the game must say which ones are palindromes. Zara's first test is Level, because a capital at the front should not matter.

Input. n, then n words. Each word is made of ASCII letters (a-z, A-Z).

Output. One line per word: yes when it reads the same backwards, with capitals and small letters treated alike, otherwise no.

Constraints. 1 <= n <= 20000. Each word has 1 to 50 letters.

Sample. Input 5 and Level noon Kenji Anna ab gives yes, yes, no, yes and no on five lines.

const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out
// Read n, then n words. Per word: yes or no.

console.log(out.join("\n"));

Graded as palindromes. The words have no spaces, so this is the token starter. Either way from Example 2 passes; pick the one you can explain.

Run in Compiler
Exercise 2Medium

Kenji sends his friends secret messages with a Caesar shift, and he likes big shifts: 29, -3, even -1000. Write the program that does any shift.

Input. Line 1 is k. Line 2 is the text. This problem uses the starter's lines variant, and the last line of the input is never empty.

Output. The text with every letter moved k places forward in the alphabet (backward when k is negative), wrapping around, capitals staying capitals. Every other character stays unchanged.

Constraints. -1000 <= k <= 1000. The text has 1 to 100000 printable ASCII characters (codes 32 to 126).

Sample. Input 3 and Hello, Zara! xyz on two lines gives Khoor, Cdud! abc.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out
// Read k, then the text. Shift every letter k places.

console.log(out.join("\n"));

Graded as caesar-shift. Start from Example 3. The hidden tests use negative shifts and shifts far past 26, so solve the brain teaser first.

Run in Compiler
Exercise 3Medium

Amara is moving her whole blog to a new host, and every old post needs a slug. Some titles are nothing but punctuation, and those posts still need an address.

Input. n, then n titles, one per line. This problem uses the starter's lines variant, and the last line of the input is never empty.

Output. One line per title, its slug. To make it, lowercase the title. Every maximal run of characters that are not a-z or 0-9 becomes one hyphen, with no hyphen at the start or the end. If nothing is left, print the word untitled.

Constraints. 1 <= n <= 10000. Each title has 1 to 200 printable ASCII characters (codes 32 to 126).

Sample. Input 3, then Hello, World! 2026, --- Strings & Template Literals --- and !!! on three lines, gives hello-world-2026, strings-template-literals and untitled.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out
// Read n, then n titles. Per title: its slug, or untitled.

console.log(out.join("\n"));

Graded as slug-maker. Example 5 handles one title; here there are many, and some leave no words at all.

Run in Compiler
Exercise 4Hard

Amara's editor shows a narrow preview of each post on a phone. The preview wraps the text so that no line is wider than the screen, and it never cuts a word in half.

Input. Line 1 is the width w. Line 2 is the text, words separated by one or more spaces.

Output. The words in order, packed into lines. Each line holds as many words as fit, separated by single spaces, at most w characters long. A word longer than w gets a line of its own.

Constraints. 10 <= w <= 80. The text has 1 to 1000 printable ASCII characters and at least one word.

Sample. Input 16 and Amara writes every day, and her readers wait for Friday. gives Amara writes, every day, and, her readers wait and for Friday. on four lines.

const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out

console.log(out.join("\n"));

Not graded on its own. Keep the line you are building in a variable, and ask one question per word: does it still fit with one space in front? Zara's tests: a width of 10 with supercalifragilistic, and a line of exactly w characters.

Run in Compiler

Common doubts

  • Does the palindrome check work for a Bangla word?

    Zara tried it. "কি".split("").reverse().join("") gives "িক": the vowel sign jumps in front of its consonant. The letter and its vowel sign are two code units, and split("") cuts between them. Lesson 05 works with the characters a reader sees.

  • Is result += inside a loop slow?

    Not at these sizes. Each += makes a new string, yet V8 avoids most of the copying people fear. Lesson 05 (Pro) times it against an array and one join.

  • Why not make the slug with a regular expression?

    You can. A regular expression is a small pattern language for text, and Module 16 teaches it. The loop shows every decision in plain code first, and lesson 04 shows where a job becomes a pattern.

  • Why does ROT13 need no separate decoder?

    Because 13 + 13 = 26, one full trip round the alphabet. Any other shift k needs the shift 26 - k to undo it.

Key takeaways

  • Use the starter's lines variant when the input is text with spaces; next() then returns a whole line, an empty one included.
  • split(" ").filter(Boolean) counts words through runs of spaces and empty lines; split, reverse and join reverse ASCII text.
  • charCodeAt, arithmetic and String.fromCharCode make a Caesar shift; % 26 wraps it, and a negative shift needs one more step.
  • split(",") parses simple CSV only: a comma inside quotes breaks it.
  • Template literals with padEnd and padStart print aligned columns; pad a string, never a number.
  • Go deeper: CP and Interview Pack, string patterns, the questions and the bug gallery (Pro).

Next, lesson 04 fixes Kenji's CSV import with a loop that understands quotes. Then it makes the judgement calls: methods or a loop, a template or +, and when the job has become a regular expression.

End of lesson 3

Mark it done, and your progress moves with you.

Next: When to Use Which: Methods or a Loop, a Template or +, and When It Is a Regex