Overview
Text is the most-handled data type in software and the least-understood: "string" hides a three-layer tower — bytes (an encoding), code points (Unicode's numbers), and grapheme clusters (what a human calls a character) — and most text bugs are confusion between layers. A few hours of deliberate study permanently cures mojibake, broken emoji slicing, "é ≠ é" comparison failures, and the annual UTF-8 incident.
Key points
- The three layers: bytes ←(encoding)→ code points ←(segmentation)→ grapheme
clusters.
écan be one code point (U+00E9) or two (e + combining accent); 👩👩👧 is one grapheme, several code points, many bytes. Every "length" API answers for exactly one layer — know which: Go'slencounts bytes, Java/JS.lengthcounts UTF-16 code units (surrogate pairs count double), Rust makes you choose, Python counts code points — none of them counts what users see. - UTF-8 won for reasons: ASCII-compatible, self-synchronizing (you can find the next character boundary from anywhere), no byte-order issues, compact for ASCII-heavy text. It is the interchange default: declare it, assume nothing — mojibake is always a write-with-one, read-with-another mismatch, and "there is no plain text" (an undeclared encoding is a guess).
- The UTF-16 legacy tax: Java, JavaScript, C#, and Windows APIs speak UTF-16 internally — surrogate pairs are where astral characters (emoji, many CJK) break naive code; slicing a string mid-surrogate produces garbage. Iterate by code point (or grapheme, with a library/ICU) — never by unit index arithmetic.
- Normalization: the same visible text has multiple encodings — NFC (composed, the storage/interchange default) vs. NFD (decomposed); compare and deduplicate after normalizing or "é" ≠ "é" bugs ensue (classic in filenames: macOS historically stored NFD). NFKC folds compatibility characters (fi → fi) — useful for identifiers/search, lossy for display.
- Case and comparison are locale-bound: uppercase of
iisİin Turkish — the famous bug family; case-insensitive comparison means case folding, and sorting means collation (locale-specific, what databases collations are about). Never lowercase security-relevant identifiers with locale-default functions. - Security edges: homoglyph spoofing (Cyrillic а vs. Latin a — IDN/identifier attacks), normalization-bypass of validation (validate after normalizing — AppSec input-handling rule applied to text), invisible characters (zero-width, bidi controls — the "Trojan Source" trick).
- To explore: the encoding zoo you still meet (Latin-1, Windows-1252, Shift-JIS — and charset detection's limits), Punycode, ICU as the reference library, how emoji ZWJ sequences work.
Practice
- "The Absolute Minimum About Unicode" (source) — Spolsky's canonical read-first; the bytes/code-points mental model in one sitting.
- UTF-8 codec + text CLI (exercise) — encode and decode UTF-8 by hand from RFC 3629, then build a small text-processing tool on top — the three-layer tower made concrete.
- Build Your Own JSON parser (source) — handle
\uXXXXescapes and surrogate pairs correctly; where astral characters go wrong in practice. - Grapheme segmentation drill (source) — implement (or exercise a library's) grapheme iteration and prove naive
.lengthmis-slices an emoji ZWJ sequence.
Related
- How a computer works — the layer below: bytes before they mean text.
- Time, timezones & datetime — the sibling "everyone gets it wrong" fundamental.
- Databases and other storage systems — column encodings and collations are this topic wearing a schema.
- AppSec fundamentals — validation and canonicalization order.