← thecodex.expert · The Codex Family of Knowledge
Tier 1 · Beginner · TypeScript Project

Word Counter

Count the words, characters, and lines in any text. Learn to take text apart and measure it.

🧠 Teaches how to think spoonfed, every age Last verified:

1 The Problem

We want a tool that takes some text — typed in or read from a file — and reports how many words, characters, and lines it has. It teaches the core string operations for breaking text into pieces and measuring them.

Where this shows up: word-count limits on forms and essays, reading-time estimates, search indexing, text analysis, validating input length. Measuring and slicing text is one of the most common jobs in software.

2 How to Think About It

Think about what “counting words” really means, before any code:

The plan — in plain English
1. Get the text. → 2. Characters = how long the text is. → 3. Words = split the text on whitespace and count the pieces. → 4. Lines = count how many lines were typed. → 5. Show the three numbers.

Get the text

Characters = length

Words = split on spaces, count

Lines = split on newlines, count

Show all three

3 The Build — explained part by part

Here is the complete counter. Read each part’s note below — you should understand the whole thing from the notes alone.

TypeScriptwordcount.ts
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

// countWords splits on any run of whitespace, mirroring Python's str.split().
export function countWords(text: string): number {
  const trimmed = text.trim();
  return trimmed === "" ? 0 : trimmed.split(/\s+/).length;
}

export function countChars(text: string): number {
  return text.length;
}

async function main(): Promise<void> {
  const rl = readline.createInterface({ input, output });
  console.log("Paste your text, then press Enter twice to finish:");

  const lines: string[] = [];
  for await (const line of rl) {
    if (line === "") break; // an empty line means "done"
    lines.push(line);
  }
  rl.close();

  const text = lines.join("\n");

  console.log(`Characters: ${countChars(text)}`);
  console.log(`Words: ${countWords(text)}`);
  console.log(`Lines: ${lines.length}`);
}

if (require.main === module) {
  main();
}
⚠ No in-browser playground here
Running real, type-checked TypeScript in the browser needs either a full copy of the compiler or a third-party CDN script — the same kind of external dependency this site avoids relying on for a core teaching example. Copy the code below and run it with Node on your own machine instead; the “Run It” section explains exactly how.
What each part does — in plain words
export function countWords(text: string): number — trims the text first, then splits on any run of whitespace with the regular expression /\s+/. Trimming first matters: splitting "" on whitespace gives [""] (one empty “word”), not zero, so we special-case the empty string.

for await (const line of rl) — the readline interface is itself an async iterable, so a plain for await…of loop reads it one line at a time. This is the same underlying mechanism as the other projects’ manual it.next() calls, just written as a loop because here we do not need to interleave it with other prompts.

if (line === "") break; — an empty line (the user pressing Enter twice) signals “done”, exactly as in the Python version.

lines.join("\n") — reassembles the collected lines into one block of text, the same shape count_words/count_chars expect.
Common mistakes — and how to avoid them
✗ Calling text.split(/\s+/) directly on an empty or all-whitespace string and reporting 1 word instead of 0.
✓ Trim the text first and special-case the empty result — splitting "" on whitespace always yields [""], never [].
✗ Splitting on a single literal space, text.split(" ").
✓ That turns runs of extra spaces into fake empty “words”. The regular expression /\s+/ treats any run of spaces, tabs, or newlines as one separator.
✗ Using rl.question() in a loop to collect lines, instead of iterating the interface.
✓ As the other projects on this page show, sequential question() calls can drop a line when input arrives quickly. Iterating the interface directly (for await) does not have that problem.

4 Test & Prove Each Part

How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.

'hello world' has 2 words
Empty text has 0 words
Extra spaces do not create fake words
Character count matches the length
TypeScriptwordcount.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { countWords, countChars } from "./word-counter";

test("'hello world' has 2 words", () => {
  assert.equal(countWords("hello world"), 2);
});

test("empty text has 0 words", () => {
  assert.equal(countWords(""), 0);
});

test("extra spaces do not create fake words", () => {
  assert.equal(countWords("  hello   world  "), 2);
});

test("character count matches the length", () => {
  assert.equal(countChars("abc"), 3);
});

Compile with npx tsc then run node --test wordcount.test.js. Pulling countWords/countChars into their own functions means the tests check plain strings — no stdin, no prompts.

5 The Interface

INPUTtexttyped lines, blank line to finish
What it expects
The quick brown fox
jumps over the lazy dog
[blank line]
OUTPUTcountscharacters, words, lines
What it returns
Characters: 43
Words: 9
Lines: 2

6 Run It & Automate It

Save the code as wordcount.ts, compile with npx tsc, and run with node wordcount.js — or run it directly with npx tsx wordcount.ts.

Run it locally
npx tsc wordcount.ts && node wordcount.js
Paste a paragraph, press Enter twice, and see the counts.

A CI tool like Jenkins runs the type-checker and tests automatically whenever the code changes — every line below has a plain explanation.

What you should see when it works
Terminala real run
Paste your text, then press Enter twice to finish:
hello world
foo bar

Characters: 19
Words: 4
Lines: 2
If it breaks — how to fix it
🚨 The word count is always one higher than expected
You are probably splitting on " " instead of /\s+/, which turns a double space into an extra empty word.
🚨 Pressing Enter once does not stop the program
The loop only stops on a truly empty line (just Enter with nothing typed). Make sure no trailing space snuck in before it.
GroovyJenkinsfile
// Jenkinsfile &mdash; runs the type-checker and tests automatically every time the code changes.
pipeline {
    agent any                                  // run on any available machine

    stages {
        stage('Get the code') {
            steps { checkout scm }             // download the latest code
        }
        stage('Set up Node') {
            steps {
                sh 'node --version'                              // confirm Node is installed
                sh 'npm install -D typescript @types/node'       // zero runtime deps &mdash; just the compiler and its Node types
            }
        }
        stage('Type-check and test') {
            steps {
                sh 'npx tsc --noEmit'                 // catch type errors before anything runs
                sh 'npx tsc'                           // compile to plain JavaScript
                sh 'node --test wordcount.test.js'            // Node's built-in test runner, no extra install needed
            }
        }
    }

    post {
        success { echo 'All tests passed.' }
        failure { echo 'A test failed &mdash; look above.' }
    }
}
🎯 Try this next — make it yours

You have a working word counter. Extend it:

  1. Count sentences too. Split on ., !, and ?. (Teaches: regular expressions with alternation.)
  2. Find the longest word. Track the max as you iterate the split words. (Teaches: Array.prototype.reduce.)
  3. Read from a file instead of stdin. Accept a filename as a command-line argument. (Teaches: process.argv and fs.readFileSync.)
  4. Show a word-frequency table. Count how many times each word appears. (Teaches: a Map<string, number> counter, as used in the log analyser project.)
What you learned
You learned to split text with a regular expression instead of a literal character, why trimming matters before you split, and how reading stdin through for await…of avoids the dropped-line trap that a sequence of question() calls can hit. Related reference: Basic Types, Type Inference.