For Day 32 of my SolveFromZero series I built the most boring-sounding tool in the world: a word and character counter. Textarea in, live stats out. I figured it would take twenty minutes. It took an afternoon, because the first line of code you'd write is wrong, and I couldn't stop pulling the thread once I noticed.
Here's the line everyone writes:
const characters = text.length;
That number is not the character count. It's the number of UTF-16 code units JavaScript uses to store the string. For plain English the two happen to be equal, so the bug hides in plain sight until real text shows up.
The demo that ruined my confidence
Type a thumbs-up into a naive counter and it reports 2 characters. Type a family emoji and it says 8. Ask a human and they say 1, every time.
"๐".length // 2
"๐จโ๐ฉโ๐ง".length // 8
"cafรฉ".length // 4 or 5, depending on how รฉ is encoded
Three different lies, three different reasons. And that last one is the sneaky one: cafรฉ can be four code points (a precomposed รฉ) or five (a plain e followed by a separate combining-accent code point). Same visible word, different count, and you never see which one got pasted in.
Three units, not one
Untangling this meant learning that "a character" is genuinely ambiguous, and there are three answers stacked on top of each other:
-
Code units โ what
.lengthgives you. UTF-16 stores anything above U+FFFF (most emoji) as a surrogate pair: two units for one symbol. This is the storage size, not a human count. -
Code points โ what
[...text].lengthorfor...ofgives you, because spreading iterates by code point. This fixes surrogate pairs, so[..."๐"].lengthis 1. Better! But still not right for the family emoji. -
Graphemes โ what a reader calls a character. A family emoji is several emoji fused with zero-width joiners (ZWJ) into one glyph. A
รฉmight be a letter plus a combining mark. A grapheme cluster merges all of that back into the single thing you actually see.
The counter has to report graphemes. Everything else is a number about computers, not about text.
The fix is already in your browser
I braced myself to implement the Unicode grapheme-cluster algorithm. I did not have to. The platform ships it:
function graphemes(str){
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
return [...seg.segment(str)].map(s => s.segment);
}
graphemes("๐จโ๐ฉโ๐ง").length // 1 โ
Intl.Segmenter is standard in every current browser and in Node. Switch granularity to "word" and it segments words too โ and crucially, it flags each piece isWordLike, so punctuation and spaces drop out and scripts written without spaces (Chinese, Japanese, Thai) still split correctly. text.split(/\s+/) counts an entire Japanese sentence as one word; Segmenter doesn't. It even takes a locale, because word-breaking genuinely depends on language.
That one API replaced every fragile thing I would have hand-rolled.
The rest falls out (with honest caveats)
Once you have grapheme and word lists, the other stats are easy โ as long as you're honest that some are estimates:
-
Sentences โ split on runs of
. ! ? โฆand count non-empty pieces. This cheerfully miscounts "Mr. Smith paid $3.50" as three sentences. Every counter makes this trade; the fix is a language model, which is overkill for a badge. -
Reading / speaking time โ pure
words รท rate. ~200 wpm for silent reading, ~130 wpm for reading aloud. - Keyword density โ word frequency after dropping stop-words (the, and, ofโฆ), which otherwise dominate every table.
- Limit meters โ and this one surprised me most.
A tweet is 280. A meta description renders to ~160. An SMS is 160 โ but only in GSM-7, the 7-bit alphabet from feature-phone days. Drop in a single emoji, curly quote, or non-Latin letter and the whole message re-encodes as 16-bit UCS-2, and the limit collapses to 70. So the SMS meter has to detect the encoding first, then pick the cap. A plain length <= 160 check is wrong for any message a real person sends.
What I actually took away
"Just count the characters" turned out to be a small lesson in humility about text. The hard part was never the arithmetic โ it was choosing the right unit, and admitting that .length answers a different question than the one users are asking.
The live version shows all three counts side by side so you can watch them diverge as you type an emoji, plus the grapheme breakdown, density table, and limit meters:
https://dev48v.infy.uk/solve/day32-word-counter.html
Type a family emoji into it and watch .length claim eight characters where you see one. That gap is the whole story.
Top comments (0)