Module 10 · Capstone - Real-World Projects ⏱ 19 min

Capstone — Build a Word Tally Tool

By the end of this lesson you will be able to:
  • Split raw text into a clean list of words
  • Count word frequencies using an object
  • Find the most frequent word and ship a reusable tool
  • Sort results with Object.entries for a ranked report

So far you have learned the pieces: functions to organise logic, arrays to hold lists, objects to map keys to values, and loops to walk through data. Now it is time to assemble them into one small, real tool: a word tally.

Given a sentence, the tool tells you how often each word appears and which word shows up most. This same pattern powers search-engine indexing, reading-level analysis, and spam detection — all built on the simple idea of counting what you have seen.

The plan has three milestones:

  1. Split the text into lowercase words.
  2. Count how many times each word appears (an object is perfect for this).
  3. Find the most frequent word.

Each milestone is a function. The final tool is just those functions snapped together.

flowchart LR
  T["input text"] --> S["split into words"]
  S --> C["count into object"]
  C --> M["find most frequent"]
  style S fill:#e0900b,color:#fff
  style C fill:#e0900b,color:#fff
  style M fill:#e0900b,color:#fff
The tally pipeline: split text into words, count into an object, pick the top word.

Milestone 1 — split into words

Raw text is messy. It has capitals, commas, exclamation marks, and extra spaces. Before you can count anything you must normalise it, or The and the will be treated as two different words and your tally will be wrong.

The recipe is three steps:

  1. Lowercase everything so case differences disappear.
  2. Split on anything that is not a letter. The regular expression /[^a-z]+/ matches runs of spaces and punctuation, so split cuts there.
  3. Filter out any empty strings left at the edges.
function splitWords(text) {
  return text.toLowerCase().split(/[^a-z]+/).filter((w) => w.length > 0);
}

The filter step matters. split on punctuation can leave an empty string at the start or end of the array, and an empty string is not a word. Dropping zero-length pieces keeps your data clean and prevents a ghost word from appearing in the final tally.

Watch how split and filter clean raw text into words.
function splitWords(text) {
  return text.toLowerCase().split(/[^a-z]+/).filter((w) => w.length > 0);
}

console.log(splitWords("Hello, world!"));
console.log(splitWords("One... two; three"));
console.log(splitWords("  spaces  everywhere  "));
Exercise

Milestone 1. Write splitWords(text) that lowercases the text and returns an array of the words (no punctuation, no empty strings).

function splitWords(text) {
  // lowercase, then split on non-letters, then drop empties
}

Milestone 2 — count the words

An object whose keys are words and values are counts is exactly the right shape for a frequency table. Walk the word array; for each word, bump its count by one.

The trick is the expression counts[word] = (counts[word] || 0) + 1. When a word appears for the first time, counts[word] is undefined. In JavaScript, undefined || 0 evaluates to 0, so the word starts at zero and is immediately incremented to one. On every later visit the word already has a real number, so || 0 is ignored and the count simply rises.

Without that guard, you would try to add one to undefined, which produces NaN. Once a tally becomes NaN, every subsequent addition stays NaN, and your entire frequency table is ruined. The one-liner || 0 is a safety net that turns the first visit into a valid starting count.

flowchart TD
  F["counts['the']"] --> C{"defined?"}
  C -->|No| Z["0"]
  C -->|Yes| V["real count"]
  Z --> A["+1 = 1"]
  V --> B["+1 = count+1"]
  style Z fill:#1e7f3d,color:#fff
  style V fill:#e0900b,color:#fff
The OR guard: undefined becomes 0 on the first visit, then the real count every time after.
Watch the tally object grow as each word is processed.
function countWords(words) {
  const counts = {};
  for (const word of words) {
    counts[word] = (counts[word] || 0) + 1;
  }
  return counts;
}

console.log(countWords(["the", "cat", "the", "dog"]));
console.log(countWords(["a", "b", "a"]));
console.log(countWords([]));
Exercise

Milestone 2. Write countWords(words) that takes an array of words and returns an object mapping each word to how many times it appears.

function countWords(words) {
  // return { word: count, ... }
}

Milestone 3 — find the top word

With the tally built, the last step is a linear search through the object's keys. Keep track of the best word seen so far and its count. Whenever you find a word with a strictly higher count, replace the leader.

Because JavaScript preserves insertion order for string keys, the first word to reach the maximum count wins ties. That is a useful property: if two words are equally common, the one that appeared first in the text is reported as the top word.

Notice that the helper is short because it reuses the two functions you already wrote. That is the power of decomposition: each piece is simple, and the final answer is just the pieces composed together.

Exercise

Milestone 3. Write topWord(text) that returns the most frequent word. (Include splitWords and countWords from the earlier milestones so your solution is self-contained.)

function splitWords(text) {
}
function countWords(words) {
}
function topWord(text) {
  // return the most frequent word
}
The finished word-tally tool, end to end.
function splitWords(text) {
  return text.toLowerCase().split(/[^a-z]+/).filter((w) => w.length > 0);
}
function countWords(words) {
  const counts = {};
  for (const word of words) {
    counts[word] = (counts[word] || 0) + 1;
  }
  return counts;
}
function topWord(text) {
  const counts = countWords(splitWords(text));
  let best = null;
  let bestCount = 0;
  for (const word of Object.keys(counts)) {
    if (counts[word] > bestCount) {
      best = word;
      bestCount = counts[word];
    }
  }
  return best;
}

const sample = "the quick brown fox the lazy dog the";
console.log(countWords(splitWords(sample)));
console.log("Top word:", topWord(sample));
Exercise

Predict it. Run the finished tool on a sentence. What does it print?

function splitWords(text) {
  return text.toLowerCase().split(/[^a-z]+/).filter((w) => w.length > 0);
}
function countWords(words) {
  const counts = {};
  for (const word of words) {
    counts[word] = (counts[word] || 0) + 1;
  }
  return counts;
}
function topWord(text) {
  const counts = countWords(splitWords(text));
  let best = null, bestCount = 0;
  for (const word of Object.keys(counts)) {
    if (counts[word] > bestCount) { best = word; bestCount = counts[word]; }
  }
  return best;
}
console.log(countWords(splitWords("to be or not to be")));
console.log(topWord("to be or not to be"));
Exercise

Transfer task. Write rankWords(text) that returns an array of [word, count] pairs sorted from most frequent to least frequent. Include splitWords and countWords so the solution is self-contained. Use Object.entries(counts) to get the pairs, and sort with b[1] - a[1] to order by count descending.

function splitWords(text) {
}
function countWords(words) {
}
function rankWords(text) {
  // return [[word, count], ...] sorted by count descending
}
flowchart LR
  C["counts object"] --> E["Object.entries"]
  E --> A["[[word,count],...]"]
  A --> S["sort by count"]
  S --> R["ranked report"]
  style E fill:#e0900b,color:#fff
  style S fill:#e0900b,color:#fff
Extending the tool: Object.entries turns the tally into sortable pairs for a ranked report.

Recap

  • Normalise text first: lowercase, split on non-letters, and filter empties.
  • An object is the natural shape for a frequency tally: keys are words, values are counts.
  • counts[word] = (counts[word] || 0) + 1 safely starts every word at zero.
  • Finding the top word is a linear scan through Object.keys.
  • Object.entries plus sort turns a tally into a ranked report.

You now have a complete, reusable tool. The same pipeline — clean, count, analyse — appears in every field that processes text, from journalism to machine learning.

Checkpoint quiz

To record how many times each word appears, which structure fits best?

In counts[word] = (counts[word] || 0) + 1, what does counts[word] || 0 do?

What does Object.entries({a: 1, b: 2}) return?

Go deeper — technical resources