JavaScript Basics: Count and Cut Text with Intl.Segmenter
A text preview should keep a combining accent or a joined emoji together. Intl.Segmenter finds grapheme and word boundaries so a program can work with those groups instead of cutting at arbitrary string positions.
These examples were tested on Linux with Node.js v24.19.0. They use built-in APIs only, require no network access, and include assertions that check the results. See MDN’s Intl.Segmenter reference. Segmentation and locale support depend on the runtime’s Unicode and ICU data; Node documents its internationalization support.
Prepare a scratch directory
You need Bash, a text editor, Node.js 24 on your PATH, and the mktemp, rm, and rmdir utilities. Keep using the same terminal. Save each named source file as UTF-8; .mjs selects ES modules. Each example runs independently.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Count grapheme clusters
Save this as count-grapheme-clusters.mjs:
import assert from 'node:assert/strict';
assert.equal(Intl.Segmenter.supportedLocalesOf(['en']).length, 1);
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = 'Ae\u0301👩💻';
const clusters = Array.from(segmenter.segment(text), part => part.segment);
assert.deepEqual(clusters, ['A', 'e\u0301', '👩💻']);
assert.equal(text.length, 8);
assert.equal([...text].length, 6);
assert.equal(clusters.join(''), text);
assert.equal(Array.from(segmenter.segment('')).length, 0);
console.log(`UTF-16 units: ${text.length}`);
console.log(`Code points: ${[...text].length}`);
console.log(`Grapheme clusters: ${clusters.length}`);Run it:
node count-grapheme-clusters.mjsOutput:
UTF-16 units: 8
Code points: 6
Grapheme clusters: 3The accent is a separate code point after e. The developer emoji contains two emoji joined by a zero-width joiner. In this test, each sequence forms one grapheme cluster. String iteration counts code points, while length counts UTF-16 units. Grapheme clusters are useful for editing text, but they do not measure terminal columns or rendered pixel width.
2. Shorten a preview without splitting a cluster
Save this as shorten-text-preview.mjs:
import assert from 'node:assert/strict';
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function preview(text, maximum) {
if (!Number.isSafeInteger(maximum) || maximum < 0) {
throw new RangeError('Expected a nonnegative integer limit');
}
const clusters = Array.from(segmenter.segment(text), part => part.segment);
if (clusters.length <= maximum) return text;
return clusters.slice(0, maximum).join('') + '…';
}
assert.equal(preview('Go 👩💻!', 4), 'Go 👩💻…');
assert.equal(preview('e\u0301x', 1), 'e\u0301…');
assert.equal(preview('Hi', 2), 'Hi');
assert.equal(preview('', 0), '');
assert.equal(preview('Hi', 0), '…');
assert.throws(() => preview('Hi', -1), RangeError);
assert.throws(() => preview('Hi', 1.5), RangeError);
console.log(preview('Go 👩💻!', 4));
console.log(`Exact fit: ${preview('Hi', 2)}`);
console.log(`Zero limit: ${preview('Hi', 0)}`);Run it:
node shorten-text-preview.mjsOutput:
Go 👩💻…
Exact fit: Hi
Zero limit: …Here the limit counts retained input clusters; the ellipsis is extra. That policy explains the zero-limit result. An exact fit receives no ellipsis. This small helper materializes the whole input, so use a bounded iteration strategy for very large text. It preserves the original Unicode spelling rather than normalizing it.
3. Keep word-like segments and their positions
Save this as find-word-segments.mjs:
import assert from 'node:assert/strict';
const text = 'Hi 👩💻, world!';
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
const words = Array.from(segmenter.segment(text))
.filter(part => part.isWordLike);
assert.deepEqual(words.map(part => part.segment), ['Hi', 'world']);
assert.deepEqual(words.map(part => part.index), [0, 10]);
for (const word of words) {
assert.equal(text.slice(word.index, word.index + word.segment.length),
word.segment);
}
assert.equal(Array.from(segmenter.segment('...'))
.filter(part => part.isWordLike).length, 0);
console.log(words.map(word => `${word.segment}@${word.index}`).join(', '));
console.log(`Word-like segments: ${words.length}`);Run it:
node find-word-segments.mjsOutput:
Hi@0, world@10
Word-like segments: 2Word segmentation also emits punctuation and spaces; isWordLike lets this example omit them. The returned index is a UTF-16 offset, so it works directly with slice(). It is not a grapheme index. Word-like classification is locale-sensitive and is not a universal linguistic word count. The ECMAScript segment-data definition explains these fields.
Clean up
In the same terminal, remove only the named example files, return to your original directory, and remove the empty scratch directory:
rm -- count-grapheme-clusters.mjs shorten-text-preview.mjs find-word-segments.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.