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.
2 How to Think About It
Think about what “counting words” really means, before any code:
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.
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();
}/\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.
text.split(/\s+/) directly on an empty or all-whitespace string and reporting 1 word instead of 0."" on whitespace always yields [""], never [].text.split(" ")./\s+/ treats any run of spaces, tabs, or newlines as one separator.rl.question() in a loop to collect lines, instead of iterating the interface.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.
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
What it expects
The quick brown fox
jumps over the lazy dog
[blank line]What it returns
Characters: 43
Words: 9
Lines: 26 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.
npx tsc wordcount.ts && node wordcount.jsPaste 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.
Paste your text, then press Enter twice to finish:
hello world
foo bar
Characters: 19
Words: 4
Lines: 2" " instead of /\s+/, which turns a double space into an extra empty word.// Jenkinsfile — 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 — 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 — look above.' }
}
}
You have a working word counter. Extend it:
- Count sentences too. Split on
.,!, and?. (Teaches: regular expressions with alternation.) - Find the longest word. Track the max as you iterate the split words. (Teaches:
Array.prototype.reduce.) - Read from a file instead of stdin. Accept a filename as a command-line argument. (Teaches:
process.argvandfs.readFileSync.) - 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.)
for await…of avoids the dropped-line trap that a sequence of question() calls can hit. Related reference: Basic Types, Type Inference.