In this playbook, we diagnose a subtle failure mode: counting JavaScript string "characters" without respecting user-perceived grapheme clusters may inadvertently split a grapheme. The contract under test is that any logic imposing a character limit on user input must count or segment extended grapheme clusters (what users perceive as single characters), not raw UTF-16 code units or code points. In other words, trimming a string to N “characters” must not break apart combined letters or emoji.
We will verify this by building a reproducible test harness: pinning Node/Chrome versions, defining synthetic test cases (including combining marks and multi-code-point emoji), and comparing the behavior of String.prototype.length (UTF-16 code units) versus Intl.Segmenter with { granularity: "grapheme" }. The expected outcomes (the manifest) enumerate, for each test string, the code-unit count, code-point count, and grapheme-cluster count. Any discrepancy triggers review: was a grapheme split? In such cases we must decide ACCEPT, REPAIR, RERUN or HOLD based on independent evidence. All steps are logged, including input hashes, environment info, and raw outputs, to support accountability.
The behavior is documented in the primary sources: MDN confirms that String.length counts UTF-16 code units, Unicode UAX #29 equates grapheme clusters with user-perceived characters, and the Intl.Segmenter API segments text accordingly. If at least one test case is not automatically checked, it is explicitly labeled NOT EXECUTED. This workflow is fully reproducible and intended to catch subtle internationalization boundary bugs at release time, not just to check if a function returns success or failure.
Define the exact admission contract
We start by defining a precise contract for grapheme-aware counting. For each input string, the intended state is that no extended grapheme cluster should be split or truncated. Concretely:
Ensure cluster alignment: If a string contains a multi-code-unit grapheme (e.g. a letter plus a combining accent or a multi-code-point emoji), the segmentation should treat that sequence as one unit. Breaking it violates the contract.
Equality of joined segments: Rejoining all Intl.Segmenter-identified segments must exactly reproduce the original string, so no user-perceived character is lost or altered. (Equivalently, the segment boundaries coincide with grapheme boundaries defined by Unicode.)
Count consistency: The count of segments (Intl.Segmenter count) should reflect the number of user-visible characters. By contrast, String.length returns UTF-16 code units, which may be larger. For example, "e\u0301" (letter "e" + combining accent) has length == 2 but should count as 1 character.
Code points vs graphemes: Some characters (like emoji or rare scripts) use surrogate pairs. Each surrogate pair is 2 code units but still 1 code point. These still form one grapheme. For instance, the emoji "😀" (U+1F600) has length == 2 but is one user-perceived character.
Procedure: We will use new Intl.Segmenter(locale, { granularity: "grapheme" }) and iterate (or spread) over its segment() result to enumerate grapheme clusters. We assert: for each test string, the number of clusters found equals the expected count from our manifest, and the concatenation of all segment strings equals the original. Any deviation (extra, missing, or partial clusters) breaks the contract.
By treating Intl.Segmenter as the oracle for "user-perceived character boundaries", we test the code under scrutiny (e.g. truncation logic, UI counters) against these criteria. This separates the question of “did the function run” from “did it produce correct grapheme segmentation?”, a lesson emphasized by Refonte’s engineering examples (e.g. Define the object that the response contract actually requires). The tool is not only to check success/failure, but to validate that the result matches the formal specification of grapheme clusters. The specific contract is to count segments without splitting clusters.
Capture baseline versions and state
Before running tests, record the runtime environment to ensure reproducibility. We pin concrete versions of Node.js and browser engines (the platforms that implement Intl.Segmenter) and lock them in. For example, we may use Node.js v20.10.0 (released Apr 2026) and Chrome v106.0.5249.118 for consistency. We document these versions exactly in the evidence ledger. We also note locale, timezone, and any relevant system info, since Intl.Segmenter behavior is locale-dependent (though grapheme segmentation itself is locale-insensitive, we standardize on en locale). From MDN: “Intl.Segmenter… has been available across browsers since April 2024”, so this is baseline behavior.
# Environment (EXPECTED)
$ node -v
v20.10.0 # pinned Node.js version (EXPECTED)
$ npm ls Intl-Segmenter
[email protected] # if using polyfill or similar (EXPECTED)
$ google-chrome --version
Google Chrome 106.0.5249.118 (EXPECTED)If we use a browser-driven test, we similarly fix Chrome/Chromium or Firefox: e.g. firefox --version. We also record platform (e.g. Ubuntu 22.04 LTS). The script that runs the tests includes a header printing these values (hashes of the source code can also be recorded). The key point is that all subsequent results are tied to this exact environment. If any behavior differs in another version, we will notice and can RETEST or ROLL BACK as needed.
Pin environment and versions
As a verification step, our harness asserts that the known version signatures appear. For instance, the test driver might do print(require('node:process').version) inside Node.js and verify it matches our pinned version. If not, the script halts. The oracle here is “the version output must match the pinned spec”. A mismatch means we REJECT the run (and rerun after correcting environment). This prevents hidden differences (like a CI update of Node) from invalidating results. The fixation of environment is analogous to how Refonte advises “The synthetic coordinates are defined in one system, with no reprojection”. Here, we fix software layers, no hidden polyfills or locale changes.
Build a disposable isolated fixture
We now construct a self-contained test harness. For example, we write a segment_test.js Node script (or a headless browser script) that imports only stable libraries, sets "use strict", and uses no network or external state. We run it in a fresh workspace each time (like a new Docker container or a clean terminal). The code includes our pinned Node version.
Steps to build the fixture:
Write test script: In segment_test.js, require no extras (maybe 'v8' or 'Intl' if needed) and create an Intl.Segmenter('en', {granularity: 'grapheme'}).
Declare test strings: Hardcode an array of synthetic inputs (strings with diacritics, emoji, ZWJ sequences). Each case has an ID.
Run segmentation: For each case, convert the segments iterator to an array and measure its length. Also compute input.length (code units) and e.g. Array.from(input).length (code points).
Output raw evidence: Print each case ID, the segments array (or joined segments with quotes), and counts. For example:
console.log
CASE ${id}: "${input}" -> segments: [${segments.map(s=>"${s.segment})}], count=${segments.length});Encapsulate in function: Ensure that code coverage is atomic, using process.exit(1) if any assertion fails.
Because we’re explicitly performing all actions in one run, we minimize external dependencies. After the script ends, it prints a summary status. (According to Refonte best practices, we note that “All code and data needed to reproduce the branches P0–P5 are shown”; here we similarly show all test code.)
Once written, the fixture should be run with Node as a disposable process. Example usage:
$ node segment_test.js(All output from this run is our evidence ledger.)Create an independent expected-case manifest
We next create an independent manifest of expected results. This is a static table or JSON authored by hand (or with a separate known-good tool) that the test should match. It lists each test string and the anticipated cluster count (and code-unit/point counts). For example:
Case | Input (hex/notation) | UTF-16 length | Code points | Grapheme clusters |
A1 | "a\u0301" | 2 | 2 | 1 |
A2 | "á" (U+00E1) | 1 | 1 | 1 |
B1 | "🇫🇷" (flag FR) | 4 | 2 | 1 |
B2 | "👩👩👧👦" (family) | 8 | 8 | 1 |
C1 | "न" (U+0928 DEVANAGARI NA + U+0940 VOWEL SIGN II) | 2 | 2 | 1 |
C2 | "ส" (U+0E2A THAI SARA AA + U+0E32 THAI SARA AA) | 2 | 2 | 1 |
We verify each cluster count using a trusted reference (e.g. a Unicode library). The manifest is committed as a JSON file (or embedded at top of the script as constants). Importantly, it is independent of the code under test; we don’t derive it from the function’s output. We also include some edge cases:
An empty string "" (clusters=0).A plain Latin string "ABC" (clusters=3, length=3).A string ending with a combining mark but no base (degenerate case, e.g. "\u0301" alone; clusters might be 0 or 1 depending on locale; Unicode rules treat it as a standalone grapheme cluster). We define the expected outcome explicitly (for simplicity, 1 cluster, since a lone combining mark is a degenerate cluster).
This manifest drives our expected results. We double-check it against Unicode UAX #29 rules. In the spirit of Refonte’s Go JSON validation playbook, which separates decoded value from contract, our manifest separates the ideal cluster counts from any code artifacts. We do not feed this expected data into the code; instead, we compare it after execution.
Preserve a negative control
Alongside valid cases, we include a negative control: a case that should clearly not merge anything. For example, the ASCII string "HELLO" has 5 clusters (each letter is a cluster). If our segmentation logic erroneously glues characters together, this control would catch it. Another negative control is two identical emoji in a row: "👋👋". Since there is no joiner, Intl.Segmenter should return them as two separate segments, not one. We assert these expected outcomes explicitly. For instance, in the fixture we might have:
const negative = "AB";
const negSegs = [...segmenter.segment(negative)];
console.logNEG_CTRL: "${negative}" -> segments: [${negSegs.map(s=>"${s.segment})}]);We expect ["A","B"]. Any deviation (e.g. ["AB"]) is a clear violation: “negative control failed”. We mark such controls as EXPECTED in the ledger. The oracle is the manifest (2 clusters for "AB"), and the rejection condition is any other count. This ensures our harness isn’t fooling us by only testing “hard” cases; it must also behave correctly on trivial ones.
Reproduce the misleading success
Often, a function may “succeed” (return without error) yet violate the contract. For example, simply using string.substring(0, N) might satisfy a length check but split a grapheme. We reproduce this potential pitfall. Consider:
const str = "e\u0301"; // "e" + combining acute
console.log(str.length); // EXPECTED output: 2
const segmenter = new Intl.Segmenter('en', {granularity: 'grapheme'});
const segs = [...segmenter.segment(str)];
console.log(segs.length); // EXPECTED: 1 (one cluster)Here length returns 2, which might lead naive code to believe there are 2 characters. But the segmenter shows 1 cluster, so a naive truncation to 2 code units actually left the string intact (not violating yet). More illustrative: if a UI limit was 1 character, taking str.substring(0,1) would drop half the cluster. We simulate that:
const limit = 1;
const truncated = str.substring(0, limit);
const segsTrunc = [...segmenter.segment(truncated)];
console.logTruncated segments:, segsTrunc.map(s=>s.segment));
// EXPECTED: ["e"] (the combining accent dropped -> grapheme split!)We show that truncation “succeeds” (no error), yet the actual clusters differ (one cluster lost its mark). This matches the lesson from Go JSON example: “A successful decode can be the most misleading green signal… None of those observations proves that the HTTP body contains exactly one JSON value”. Here, a “successful count” doesn’t prove the cluster remained whole. We output this evidence. The assertion is that the original segment list vs truncated segment list are not equal (contract broken).
Run the comparison case
Now we execute the full set of test cases against our expected manifest. In code:
const cases = [
{id:"A1", input:"a\u0301", expectedClusters:1, expectedUnits:2},
{id:"A2", input:"á", expectedClusters:1, expectedUnits:1},
// ... (other cases, including emoji)
];
for (const {id,input,expectedClusters,expectedUnits} of cases) {
const units = input.length;
const segs = [...segmenter.segment(input)];
const count = segs.length;
console.logCASE ${id}: input=${JSON.stringify(input)});
console.log codeUnits=${units}, codePoints=${Array.from(input).length}, segments=${count});
console.log segments detail: [${segs.map(s=>"${s.segment}).join(",")}]);
if (units !== expectedUnits) {
console.error${id}: Unexpected code unit count (expected ${expectedUnits}));
}
if (count !== expectedClusters) {
console.error${id}: Cluster count mismatch (expected ${expectedClusters}));
}
// We do NOT auto-fail; we record errors and summarize.
}Each line of output is raw evidence: we list the actual segment list for each case and the counts. For example (EXPECTED / NOT EXECUTED):
CASE A1: input="á"
codeUnits=2, codePoints=2, segments=1
segments detail: ["á"](This string includes the combining mark but shown attached.) If any console.error line appears, that is a failure evidence. We will mark all such prints with EXPECTED because these are not user-viewable logs but our own test output. The oracle checks two things: units == expectedUnits (sanity check) and count == expectedClusters (contract check). The rejection condition is count != expectedClusters. If rejection triggers (nonzero exit), the run is a failure.
Below is an illustrative summary of the test run (EXPECTED / NOT EXECUTED):
$ node segment_test.js
CASE A1: input="á"
codeUnits=2, codePoints=2, segments=1
segments detail: ["á"]
CASE A2: input="á"
codeUnits=1, codePoints=1, segments=1
segments detail: ["á"]
CASE B1: input="🇫🇷"
codeUnits=4, codePoints=2, segments=1
segments detail: ["🇫🇷"]
...
# EXPECTED: all cases print their segment arrays and counts;
# no unexpected console.error.Detect missing or duplicated evidence
We verify that the number of printed cases matches the manifest size. For instance, if the manifest has 6 cases, our script should output exactly 6 “CASE” entries. We include an assertion after the loop:
require(casesRun === cases.length, Case inventory: found ${casesRun} of ${cases.length});This parallels Refonte’s “case inventory check”. If the counts differ, we reject (maybe process.exit(2)). We also ensure that no case ID is printed twice (the manifest IDs are unique). This prevents silent omissions or duplicates in the code. In a final report, the evidence ledger will list each case only once. Any discrepancy leads to HOLD (unknown state) or a rerun if we suspect a harness bug.
Test negative and boundary controls
We now extend testing with negative and boundary cases. For example, test an empty string and long strings:
console.logNEG: empty -> segments=${[...segmenter.segment("")].length}); // EXPECTED: 0
console.logNEG: "ABC" -> segments=${[...segmenter.segment("ABC")].length}); // EXPECTED: 3
console.logBOUNDARY: long cluster -> segments=${[...segmenter.segment("क्" + "ि" + "न")].length}); // Devanagari, EXPECTED:1Empty string: Expect 0 segments. (A non-zero result is an error.)
Simple ASCII: “ABC” should give 3 segments. If the code somehow merges them, that fails.
Complex cluster at length boundary: For instance, joining a consonant + vowel sign in Hindi ("क्" + "ि" + "न") yields one combined grapheme “किन”. We test that it is one segment. If an engine splits e.g. due to font intricacy, we catch it. This is an application of UAX #29: “Spacing combining marks” (Indic vowel signs) should attach to their base.
We mark each result as EXPECTED / NOT EXECUTED. For example:
NEG: empty -> segments=0 # EXPECTED: should be 0
NEG: "ABC" -> segments=3 # EXPECTED: should be 3
BOUNDARY: "क् + ि + न" -> segments=1 # EXPECTED: Devanagari cluster count 1If any of these is wrong, we would categorize the run as REPAIR (if a workaround exists) or HOLD if it violates fundamental Unicode assumptions.
Collect per-case raw evidence
We compile all raw evidence into a ledger. For each test case, we record:
Environment: Node/Chrome versions (as above).
Input: hex or code form (so combining marks are visible).
Expected: our manifest values.
Observed segments: the list of segments from Intl.Segmenter, plus counts.
Decision: PASS or FAIL for each case (based on cluster match).
For example, an entry might look like JSON for clarity (EXPECTED / NOT EXECUTED):
{
"case": "B1",
"input": "🇫🇷",
"hex": "U+1F1EB U+1F1F7",
"expected_clusters": 1,
"expected_units": 4,
"observed_clusters": 1,
"segments": ["🇫🇷"],
"result": "PASS"
}Each such object is appended to a JSON array (our evidence ledger). If a test fails, result is "FAIL_SPLIT" or similar. We explicitly do not alter the code to hide failure; instead we record it. This separation of expectation vs observation mirrors Refonte’s style of keeping expected results and actual outputs in parallel (see the SQLite JSON and HTTP message validation examples). The ledger is crucial for the final decision. If any "FAIL_SPLIT" entries exist, we must examine them.
Check the actual state after failure
If a failure is detected (a split grapheme), we inspect the actual string and segment boundaries. We ensure the original string state has not been mutated (the segmenter is read-only). We also check the last code point of each segment. For example, if segment “a” was output instead of “á”, we note that "input[segmentStartIndex..segmentEndIndex]" reveals the combining mark. We do not just rely on the console logs; we capture segment start/end indices via segmentData.index if needed:
segs.forEach(seg => {
console.log seg[${seg.index}-${seg.index+seg.segment.length}]: "${seg.segment}");
});This confirms which part of the input each segment covers. If a grapheme was split, we should see a segment that ends with a base letter and the next segment starts with a combining mark. By examining these indices (all zero-based), we verify whether the segmentation truly covers the entire string correctly or not. This step is analogous to verifying “remaining == BODY and not defects” in Refonte’s HTTP message validation example. We make sure nothing in the string is unaccounted for. No evidence is dropped. If we detect a split (the joining of segments doesn’t rebuild the input exactly), we mark that case for REPAIR.
Reconcile identities and counts
We now reconcile the segments with the original string. We check that concatenating all observed segments in order yields the exact original input. In code:
const rejoined = segs.map(s=>s.segment).join('');
if (rejoined !== input) {
console.error${id}: Rejoined segments "${rejoined}" != original "${input}");
}This ensures we counted exactly the right characters. We also confirm the count of code units: sum(seg.segment.length) == input.length. Mismatches would indicate either missing data or duplicate coverage, which should not happen. In our tests we specifically handle the case of identical repeated graphemes (e.g. two identical emoji). The algorithm must treat them as separate segments: e.g. "👋👋" yields ["👋","👋"], not ["👋👋"]. This aligns with the logic of Refonte’s HTTP message validation playbook where each header occurrence is separate: we effectively "count identical values and names separately". In our context, identical grapheme clusters in sequence count multiple. We verify that by checking that if the input has repeating sequences, each appears distinctly in segments.
If everything aligns (the identity of each segment matches input substrings, no duplication), that case is PASS. If not, we flag it. We also tally how many clusters we found vs expected. If the identity check fails, it suggests a logical bug in segmentation (unlikely but we consider it). These reconciliation checks ensure internal consistency. Any discrepancy triggers a human review or a HOLD.
Explain documented behavior and limits
We document how Intl.Segmenter is supposed to behave by specification and practical limits. According to the Unicode Standard (UAX #29), grapheme clusters are sequences defined so that user-perceived characters are atomic units. The default “extended grapheme clusters” profile is used by Intl.Segmenter, meaning it follows rules that include combining marks (\p{M}) and zero-width joiners as part of a cluster. In the ECMAScript Internationalization spec, the Segmenter returns a Segments object whose iterator yields contiguous segments based on these rules.
Crucially, no segmenter will break a cluster unless the algorithm or data is wrong. For example, Thai characters (which use dependent vowel signs) should be segmented with vowels attached to the base. Similarly, emoji ZWJ sequences (like family emoji) form a single segment. If we observe otherwise, it indicates either a bug in our environment or an unsupported custom behavior. We cite MDN: "String.length returns the number of UTF-16 code units, not the actual number of ‘user-perceived characters’ (grapheme clusters)." Thus a platform’s documented behavior expects the differences we are testing for.
One limit: Intl.Segmenter can vary by locale (some scripts might have locale-specific breaks), but for grapheme granularity it’s effectively locale-agnostic. Another limit: segmentation is a "best-effort" under Unicode rules; in rare cases (e.g. non-canonical text) it may not match user intuition, but it is the spec’s authoritative rule. UAX #29 notes “As an approximation, [grapheme clusters] may not cover all user-perceived characters...special cases may require tailoring”. In our tests, we use normalized inputs so canonical equivalence holds. We also note that there is no fixed maximum on cluster length; a single grapheme could be arbitrarily long (e.g. "g" + many combining marks) and a cluster count-based limit should handle that. This means any limit by clusters is strictly a count of segments, not a byte or code-unit limit.
Bound the repair to the contract
If we find a splitting failure, any repair must stay within the defined contract. We do not change the definition of a cluster to fudge a fix. For example, one might imagine forcing segmentation by some custom logic, but that would violate the spec. Instead, the repair will be something like “truncate the string to the last whole grapheme” or “reassemble the lost combining mark”. This is analogous to how a Refonte blog might say "the repair is not to lowercase the credential value"; we repair only within the defined space of the problem.
Apply the smallest safe repair
Assuming a cluster was split (detected by comparing actual vs expected segments), we apply a minimal fix. One safe repair is to trim the offending trailing code unit. For instance, if "a\u0301b" (e with accent + “b”) was truncated to "a\u0301", that is fine. But if it was incorrectly broken into "a" and "́" and then "b", we would remove the dangling "́" (or reattach it to "a"). In code, we might do:
if (/[\p{M}]/u.test(input.slice(-1))) {
// last code point is a combining mark, drop it
fixedInput = input.slice(0, -1);
} else {
fixedInput = input;
}
const fixedSegments = [...segmenter.segment(fixedInput)];
console.logFixed segmentation:, fixedSegments.map(s=>s.segment));We then re-evaluate the cluster count. This is the smallest change: we only drop or move the minimal part of the string. If that yields the expected count, we can ACCEPT the procedure (with a note that user input was trimmed to whole clusters). Repairs should take only necessary steps and not much more. Define the maintenance promise in values, not adjectives; similarly, make a precise fix.
If trimming is not enough (e.g. splitting logic was applied in the middle of a cluster), we document that the scenario is beyond a simple fix. In rare cases, the correct action might be to reject the input or HOLD for manual review. However, by constructing our test cases carefully, we expect any fix to be either trivial trimming or reassembly of combining marks (both safe operations). We do not invent new graphemes.
Test rollback and rerun safety
In our isolated test, “rollback” is conceptual: if we applied a repair (like dropping a code unit), we can re-run the same test on the repaired string to confirm the fix. Because our fixture is ephemeral, rolling back to the original string simply means not committing any changes. In practice, we simulate this by running the segmenter twice: once on the original string (test), and then on the fixed string (repair). We ensure that re-running the harness yields the same or improved result.
For example, suppose input was "न"+"ि"+"न" (Devanagari with vowel) and incorrectly segmented. We might “rollback” to original and try slicing differently. We log both runs. A PostgreSQL rollback would restore the prior state. Here, if trimming was wrong, we still have the untouched input to try another approach.
We also verify idempotence: repeating the segmentation on the original environment produces consistent results. If the first run left any side-effects (it shouldn’t), the second run should find the same problem. This is like ensuring the test harness cleans up after itself.
Assign operational ownership
We document who is responsible for each part of this process. The test harness code is owned by the release engineer (me); the interpretation of results is also my responsibility. If a failure requires a code change in the application, the developer team will implement a fix. Any ambiguous case (say, a very exotic script cluster) is escalated to the i18n team. We capture this in the decision matrix (below). This is similar to the assignment of ownership for DB tasks, though in our context it’s more straightforward: test code owner vs product code owner.
Decide ACCEPT, REPAIR, RERUN or HOLD
Finally, we map outcomes to actions. For each case (or in aggregate), we follow this decision table:
Observations | Decision | Action |
All segments match expected cluster counts and exact match to input | ACCEPT | No change needed; proceed with merge. |
Segment count is lower than expected (cluster was split) and a safe fix (e.g. drop trailing mark) restored the contract | REPAIR | Apply the fix to truncate to last whole grapheme; accept updated input. |
Test environment inconsistency (version mismatch, etc.) | RERUN | Resolve environment (re-pin) and rerun tests; do not change code. |
Unexpected result not covered (e.g. spec violation) or incomplete evidence | HOLD | Escalate for manual investigation; do not proceed. |
For example:
If all cases pass (clusters aligned), we ACCEPT the release for this check.
If one case shows a split that we can fix by trimming a combining character, we REPAIR (with minimal patch) and re-evaluate that case.
If something is unclear (maybe our harness missed something), we RERUN after adjusting tests.
If a test fails unpredictably, we HOLD the release for human review.
At the end, we record one overall disposition with evidence. The operator (release engineer) will not just trust “0 failures” from the tool; we examine the evidence ledger JSON (manually or via script) to confirm. Only if every row passes the cluster checks do we tag the check as ACCEPT.
Each result is tied to specific environment data, input hashes, and expected values. Unsupported cases or version mismatches would trigger a RERUN or HOLD status.
Develop related skills with Refonte Learning
For a deeper dive into front-end development practices (including robust internationalization), consider Refonte Learning’s programs. Refonte offers a structured Full Stack Developer Program (which covers both frontend and backend web development) that runs 3 months at 12–14 hours/week. This program includes hands-on projects (HTML, CSS, JavaScript, React/Angular for frontend; Node.js/Express and databases for backend), plus a virtual internship and weekly mentoring. Upon completion, participants earn a Training Certificate and an Internship Certificate. (Applicants are expected to be pursuing at least a bachelor’s degree.) If you want to learn in-depth about JavaScript, Unicode, and user interfaces in a guided environment, Refonte’s curriculum is a valuable resource. Enroll or inquire at Refonte Learning’s official Full stack Developer Program page to strengthen the skills used in this playbook.
