Beyond the Code Unit: Text Utilities That Survive Real User Input
A string type is not a model of text. It is a model of storage. That distinction is easy to miss when most test data is ASCII and most failures appear only after real users arrive. The first principle is straightforward: users do not type bytes, code points, or code units. They type characters as they perceive them, and they expect software to preserve those characters when it truncates, sorts, compares, and searches.
The Byte, the Point, and the Cluster
At the bottom, text is bytes. UTF-8 encodes code points into one to four bytes. A code point is an integer assigned by Unicode. A grapheme cluster is what a reader perceives as one character. The layers do not line up. The letter e with an acute accent can be one code point or two: a base letter plus a combining mark. A family emoji can contain several code points joined by zero-width joiners. In JavaScript, string.length counts UTF-16 code units, so an emoji outside the basic plane counts as two. In Python, len counts code points, so a combining sequence counts as two. Neither count is a user-facing character count.
That is the problem in one sentence: a string is an encoding of text, not text itself. Once a team accepts that, the utilities follow.
Failure Case One: The Length Limit That Splits a Name
A profile form caps display names at 20 characters. The server calls slice(0, 20) or takes the first 20 elements of a code point list. A name with a combining accent survives as a broken sequence. An emoji with a skin-tone modifier loses its modifier. A flag emoji is split into regional indicators. The database accepts the result, the UI renders a replacement glyph, and the customer sees a corrupted identity.
The fix is not a bigger limit. It is a different unit. Segment by grapheme cluster before truncating:
function truncateGraphemes(value, max) {
const segmenter = new Intl.Segmenter('en-US', { granularity: 'grapheme' });
const parts = [];
for (const part of segmenter.segment(value)) {
if (parts.length === max) break;
parts.push(part.segment);
}
return parts.join('');
}
This function respects what a reader sees. It does not make byte limits disappear. If a column is limited in bytes, validate against UTF-8 byte length separately, and return a clear error instead of silently cutting. The same rule applies to code point limits in older APIs: measure the unit the system enforces, and display a message in the unit the user understands.
Failure Case Two: Sorting That Betrays the Directory
Default string comparison is a code unit comparison. It puts uppercase before lowercase, places accented letters after unaccented ones, and ignores the collation rules users learn in school. A directory sorted this way looks broken even when every record is correct.
Use a collator for display order:
const displayCollator = new Intl.Collator('en-US', {
sensitivity: 'base',
numeric: true,
});
names.sort(displayCollator.compare);
The collator is for presentation. It is not an identity function. With base sensitivity, two different names can compare equal. Do not use that result as a uniqueness key, a database index, or an authorization check. Keep a separate exact key for identity and a collated key for ordering.
Failure Case Three: Search That Normalizes Too Late
Unicode normalization matters because the same visible text can have multiple encodings. A name typed on one platform may be stored in composed form; the same name pasted from another source may arrive decomposed. A search for the composed form can miss the decomposed record if comparison happens before normalization.
Normalize at the boundary. Store a canonical form, usually NFC for names, and build a search key from that canonical value. Then apply case handling according to a documented locale policy. Do not assume that toLowerCase is universal: Turkish dotted and dotless i, German sharp s, and other locale rules make case-insensitive matching a locale-sensitive operation. For equality checks in search, use a collator with the sensitivity you actually mean, and test the locales you support. Normalization fixes encoding differences; it does not erase case or locale differences by itself.
A practical search key looks like this:
function searchKey(value) {
return value.normalize('NFC').toLocaleLowerCase('en-US');
}
That is a starting point, not a global policy. If the product serves users outside the United States, the locale argument should come from a deliberate decision, and the tests should include Turkish and German fixtures.
A Small Utility Kit
The utilities are small enough to own. Build four functions: grapheme length, grapheme-safe truncation, display comparison, and search key generation. Put them behind tests with fixtures that ASCII-only suites never catch: a precomposed accented name and its decomposed twin, an emoji with a skin-tone modifier, a zero-width-joiner family emoji, a flag emoji, Turkish dotted and dotless i, German sharp s, and a CJK string with no spaces. Assert that truncation never returns a dangling combining mark. Assert that display sorting is stable for the product's chosen locale. Assert that search matches the composed and decomposed forms of the same name.
This is the CodeOrigin pattern in miniature: start from the representation, name the layers, then write the smallest code that respects them. The result is not a Unicode library. It is a set of decisions your team can defend.
Checklist Before Shipping
- Decide which unit each limit measures: bytes, code points, UTF-16 code units, or grapheme clusters.
- Normalize at the storage boundary and document the form you store.
- Truncate user-facing text by grapheme cluster, never by raw string length.
- Sort display text with Intl.Collator, and keep identity keys separate.
- Treat case-insensitive search as locale-sensitive and test beyond English.
- Add global fixtures to the test suite before the first international customer arrives.
- Return validation errors rather than silently repairing names.
Text utilities are infrastructure. They deserve the same review as authentication or payments, because a corrupted name is a corrupted customer record. The path from first principles to working code is short, but only if the team agrees on what a character is.