Most string operations aren't safe

I have been doing common string operations incorrectly for most of my career, and I suspect you have too.

Take this truncation function:

public static string Truncate(string value, int maxLength)
{
    return value.Length > maxLength
        ? value.Substring(0, maxLength)
        : value;
}

Looks fine. Here’s what it does with a string containing an emoji:

var hello = "hello! πŸ‘‹ nice to meet you";
Console.WriteLine(Truncate(hello, 8)); // prints "hello! οΏ½"

The output should have been hello! πŸ‘‹, but instead the emoji became a replacement character (U+FFFD: οΏ½). Something clearly went wrong.

This second failure is more subtle:

var hello = "hi πŸ‘‹πŸ½";
Console.WriteLine(Truncate(hello, 5)); // prints "hi πŸ‘‹"

We silently dropped the skin tone modifier! The output still has a waving hand, but it’s the wrong one. This kind of error is easily missed.

The root cause isn’t specific to truncation. Any operation that treats a string as a sequence of positions is simply working on the wrong unit. This includes slicing, indexing, measuring length, splitting, reversing.

My examples are all in C# and JavaScript but the ideas carry over to any language you’ll use.

Why you can’t avoid it

The tempting response is to validate lengths upfront and reject anything too long. If nothing needs truncating, we won’t encounter the bug in the first place.

That works when you own the full data lifecycle. It doesn’t work in a very common situation: integrating with a third-party system that has its own field length limits.

Consider a scenario where you have a product catalogue with descriptions of varying lengths, and you need to push them to a partner API that caps descriptions at 100 characters. You have thousands of products. At some point, something is going to truncate.

Even without third-party integrations, anyone who has used SQL Server knows this error:

String or binary data would be truncated.

Most SQL databases let you define string columns with a maximum length e.g. varchar(100) or nvarchar(100). Attempting to insert a value that exceeds the limit causes the query to fail. These length limits consequently propagate to the application layer, and something eventually has to fit the data to the constraint.

What is a “character”?

The core problem is that a string isn’t a sequence of characters, at least not in the way most developers are used to.

Unicode is the standard that catalogues every known character and assigns each one a number called a code point, referenced as U+ followed by a hex number. The waving hand emoji πŸ‘‹ is code point U+1F44B. There are over a million possible code points.

To actually store code points in memory, you need an encoding. An encoding translates a code point into one or more code units, the raw values a computer reads and writes. The two most common encodings are UTF-8 (8-bit code units) and UTF-16 (16-bit code units).

Some code points require more than one code unit. In UTF-16, πŸ‘‹ is two code units: D83D DC4B. In UTF-8, it’s four: F0 9F 91 8B. We can see that there’s clearly a different byte-level representation of the same codepoint, despite occupying the same amount of storage space.

In C# and JavaScript, strings are sequences of UTF-16 code units. That’s what string.Length and s.length count, meaning that πŸ‘‹ has a string length of 2 in those languages.

Graphemes

Some characters that appear as a single unit on screen are actually made of multiple code points. These are called graphemes. Combining diacritics are a common example as ΓΌ can be represented as the letter u (U+0075) followed by a combining diaeresis (U+0308):

  • u + β—ŒΜˆ β†’ ΓΌ (U+0075 U+0308)
  • n + β—ŒΜƒ β†’ Γ± (U+006E U+0303)
  • u + β—ŒΜˆ + β—ŒΜƒ β†’ ΓΌΜƒ (U+0075 U+0308 U+0303)

The zero-width joiner (ZWJ, U+200D) can be used to combine emoji into new ones:

  • πŸ‘¨ + ZWJ + 🍼 β†’ πŸ‘¨β€πŸΌ (U+1F468 U+200D U+1F37C)
  • πŸ‘© + ZWJ + 🍼 β†’ πŸ‘©β€πŸΌ (U+1F469 U+200D U+1F37C)

Country flag emoji are built from regional indicator symbols, one per letter in the country code:

  • πŸ‡¦πŸ‡Ί: regional indicator A (U+1F1E6) + regional indicator U (U+1F1FA)
  • πŸ‡ΊπŸ‡Έ: regional indicator U (U+1F1FA) + regional indicator S (U+1F1F8)

Any sequence of one or more code points that renders as a single visible character is a grapheme. The hierarchy is:

  • A grapheme is what you see as one character, e.g. πŸ‡¦πŸ‡Ί
  • A grapheme is made of one or more code points, e.g. U+1F1E6 and U+1F1FA
  • A code point maps to one or more UTF-16 code units, e.g. D83C DDE6 and D83C DDFA

You can’t trust .length

Every row below is one visible character. It shows what each of the three layers look like, and also demonstrating that the number of code units is what .length actually reports:

StringContentsGraphemesCode points.length
ΓΌu + combining diaeresis122
Γ±n + combining tilde122
ΓΌΜƒu + diaeresis + tilde133
πŸ‘¨β€πŸΌman + ZWJ + baby bottle135
πŸ‘©β€πŸΌwoman + ZWJ + baby bottle135
πŸ‡¦πŸ‡Ίregional indicators A + U124
πŸ‡ΊπŸ‡Έregional indicators U + S124

You can verify in a JavaScript console:

"ΓΌ".length;  // 2
"Γ±".length;  // 2
"ΓΌΜƒ".length;  // 3
"πŸ‘¨β€πŸΌ".length; // 5
"πŸ‘©β€πŸΌ".length; // 5
"πŸ‡¦πŸ‡Ί".length; // 4
"πŸ‡ΊπŸ‡Έ".length; // 4

The values are identical in C# as both languages store strings as sequences of UTF-16 code units.

What string operations are really doing

With this foundation, the opening failures make sense.

When Truncate("hello! πŸ‘‹ nice to meet you", 8) slices at code unit 8, it cuts through the middle of πŸ‘‹’s two-code-unit representation (D83D DC4B), leaving behind an orphaned D83D. That’s not a valid UTF-16 sequence on its own, so it renders as the replacement character (U+FFFD).

The skin tone example is more instructive. "hi πŸ‘‹πŸ½" breaks down like this:

PositionCode unitPart of
00068h
10069i
20020(space)
3D83DπŸ‘‹ first surrogate
4DC4BπŸ‘‹ second surrogate
5D83C🏽 skin tone first surrogate
6DFFD🏽 skin tone second surrogate

Slicing at 5 code units yields a valid string as the πŸ‘‹ surrogate pair is intact but the skin tone modifier is silently gone. There’s not even a replacement character to indicate a wrong result.

Every positional string operation has this property as it acts on code units, not graphemes. A Substring() that doesn’t produce οΏ½ may still be dropping or stranding code points that belong together.

Fixing string operations

The fix to problem is to use APIs that understand graphemes. Let’s build up from the bottom.

Enumerating characters

This is the naive and broken approach that most code probably looks like.

C#’s foreach (char c in s) and JavaScript’s indexed loop both iterate over UTF-16 code units:

string s = "hΓ©llo πŸ‘‹";
foreach (char c in s)
{
    Console.WriteLine($"U+{(int)c:X4} {c}");
}
const s = "hΓ©llo πŸ‘‹";
for (let i = 0; i < s.length; i++) {
    const codeUnit = s.charCodeAt(i);
    const codeUnitHex = codeUnit.toString(16).toUpperCase().padStart(4, "0");
    console.log(`U+${codeUnitHex} ${s[i]}`);
}

Both produce:

U+0068 h
U+00E9 Γ©
U+006C l
U+006C l
U+006F o
U+0020  
U+D83D οΏ½
U+DC4B οΏ½

πŸ‘‹ was split into its two surrogate halves, neither of which is a valid character.

Enumerating by code point

This is an improvement as code points keep surrogate pairs together, so πŸ‘‹ appears as one unit instead of two broken halves.

C# has a Rune type that represents a single Unicode code point. string.EnumerateRunes() iterates over them:

using System.Text;

string s = "hΓ©llo πŸ‘‹";
foreach (Rune rune in s.EnumerateRunes())
{
    Console.WriteLine($"U+{rune.Value:X4} {rune}");
}

JavaScript’s for...of loop already iterates by code point:

const s = "hΓ©llo πŸ‘‹";
for (const char of s) {
    const codePoint = char.codePointAt(0);
    const codePointHex = codePoint.toString(16).toUpperCase().padStart(4, "0");
    console.log(`U+${codePointHex} ${char}`);
}

Both produce:

U+0068 h
U+00E9 Γ©
U+006C l
U+006C l
U+006F o
U+0020  
U+1F44B πŸ‘‹

πŸ‘‹ is one unit. But code points still aren’t graphemes. Adding a skin tone modifier and causes the gap to reappear:

U+1F44B πŸ‘‹
U+1F3FD 🏽

Slicing between the grapheme’s code points silently discards the skin tone.

Enumerating by grapheme

This is what we’re really looking for!

C# provides the StringInfo class that contains all of the information we need for graphemes. Importantly, StringInfo.GetTextElementEnumerator enumerates graphemes. Here’s a helper extension method and a diagnostic printer, used for the rest of this post:

using System.Globalization;

public static IEnumerable<string> EnumerateGraphemes(this string s)
{
    var enumerator = StringInfo.GetTextElementEnumerator(s);
    while (enumerator.MoveNext())
        yield return enumerator.GetTextElement();
}

public static void PrintGrapheme(string grapheme)
{
    foreach (char c in grapheme)
        Console.Write($"{(int)c:X4} ");
    Console.WriteLine(grapheme);
}

JavaScript’s Intl.Segmenter with granularity: "grapheme" does the same. Here’s the equivalent helper:

const printGraphemeInfo = (s) => {
    const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
    for (const { segment } of segmenter.segment(s)) {
        const utf16 = [];
        for (let i = 0; i < segment.length; i++) {
            utf16.push(segment.charCodeAt(i).toString(16).toUpperCase().padStart(4, "0"));
        }
        console.log(`${utf16.join(" ")} ${segment}`);
    }
};

Now enumerate "hΓ©llo πŸ‘‹πŸ½":

string s = "hΓ©llo πŸ‘‹πŸ½";
foreach (var g in s.EnumerateGraphemes())
    PrintGrapheme(g);
const s = "hΓ©llo πŸ‘‹πŸ½";
printGraphemeInfo(s);

Both produce:

0068 h
00E9 Γ©
006C l
006C l
006F o
0020  
D83D DC4B D83C DFFD πŸ‘‹πŸ½

πŸ‘‹πŸ½ is a single grapheme comprised of four UTF-16 code units but rendered as one character. Slicing is now safe.

Let’s confirm with the flag and ZWJ examples:

foreach (var g in "zwj πŸ‘¨β€πŸΌ".EnumerateGraphemes()) PrintGrapheme(g);
foreach (var g in "flag πŸ‡¦πŸ‡Ί".EnumerateGraphemes()) PrintGrapheme(g);
printGraphemeInfo("zwj πŸ‘¨β€πŸΌ");
printGraphemeInfo("flag πŸ‡¦πŸ‡Ί");

Both produce:

007A z
0077 w
006A j
0020  
D83D DC68 200D D83C DF7C πŸ‘¨β€πŸΌ

0066 f
006C l
0061 a
0067 g
0020  
D83C DDE6 D83C DDFA πŸ‡¦πŸ‡Ί

The ZWJ emoji and the flag each enumerate as a single unit.

Correct string length

Counting graphemes follows directly from the enumeration approach.

C# exposes this directly on StringInfo:

public static int GraphemeLength(this string s)
    => new StringInfo(s).LengthInTextElements;

JavaScript spreads the segmenter’s output into an array and takes its length:

const graphemeLength = (s) => {
    const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
    return [...segmenter.segment(s)].length;
};

Compare the naive length against the grapheme count for "hi πŸ‘‹πŸ½":

"hi πŸ‘‹πŸ½".Length          // 7  (h + i + space + 4 code units for the emoji)
GraphemeLength("hi πŸ‘‹πŸ½") // 4  (h, i, space, πŸ‘‹πŸ½)
"hi πŸ‘‹πŸ½".length           // 7  (wrong)
graphemeLength("hi πŸ‘‹πŸ½")  // 4  (correct)

The correct Truncate()

Now we can make our naive Truncate() grapheme-aware. First we have to answer a question that we weren’t aware of previously, truncate to what? We could mean code units, code points, or graphemes. Almost always you want code units or graphemes, and rarely code points.

Truncate by code units when you’re targeting a storage size. Returning to the database example, if the string must fit in a varchar(100) then code units are what the limit counts.

Truncate by graphemes when the limit is user-visible. If your app trims a message preview to 50 characters, users expect πŸ‘‹ to count as one. Code units would make it count as two.

Truncating by grapheme count

C# already has something which does most of the work for us: StringInfo.SubstringByTextElements, which takes a length in graphemes:

using System.Globalization;

public static string TruncateByGraphemes(string value, int maxGraphemes)
{
    var info = new StringInfo(value);
    return info.LengthInTextElements <= maxGraphemes
        ? value
        : info.SubstringByTextElements(0, maxGraphemes);
}

In JavaScript, we can accumulate segments until the limit is reached:

function truncateByGraphemes(value, maxGraphemes) {
    const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
    let result = "";
    let count = 0;
    for (const { segment } of segmenter.segment(value)) {
        if (count >= maxGraphemes) break;
        result += segment;
        count++;
    }
    return result;
}

Back to the two failing examples from the beginning:

Console.WriteLine(TruncateByGraphemes("hello! πŸ‘‹ nice to meet you", 8)); // "hello! πŸ‘‹"
Console.WriteLine(TruncateByGraphemes("hi πŸ‘‹πŸ½", 5)); // "hi πŸ‘‹πŸ½"
console.log(truncateByGraphemes("hello! πŸ‘‹ nice to meet you", 8)); // "hello! πŸ‘‹"
console.log(truncateByGraphemes("hi πŸ‘‹πŸ½", 5)); // "hi πŸ‘‹πŸ½"

The first example now ends with the complete emoji. The second example returns the string unchanged as it has only 4 graphemes, so a limit of 5 truncates nothing.

Truncating to a code-unit limit

This is the case that matches the problems from the beginning of the post. An approach we can take is to accumulate as many graphemes as possible until we exceed our storage threshold.

For the C# example, let’s reuse the EnumerateGraphemes() helper defined earlier where grapheme.Length gives its UTF-16 code-unit size:

public static string TruncateToCodeUnits(string value, int maxCodeUnits)
{
    if (value.Length <= maxCodeUnits)
        return value;

    var result = new StringBuilder();
    int used = 0;
    foreach (var grapheme in value.EnumerateGraphemes())
    {
        if (used + grapheme.Length > maxCodeUnits)
            break;
        result.Append(grapheme);
        used += grapheme.Length;
    }
    return result.ToString();
}

In JavaScript segment.length is the UTF-16 code-unit size:

function truncateToCodeUnits(value, maxCodeUnits) {
    if (value.length <= maxCodeUnits) return value;
    const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
    let result = "";
    for (const { segment } of segmenter.segment(value)) {
        if (result.length + segment.length > maxCodeUnits) break;
        result += segment;
    }
    return result;
}

Back to the string that mangled at the top of the post, with the same limit of 8 code units:

TruncateToCodeUnits("hello! πŸ‘‹ nice to meet you", 8); // "hello! "
TruncateToCodeUnits("hello! πŸ‘‹ nice to meet you", 9); // "hello! πŸ‘‹"
truncateToCodeUnits("hello! πŸ‘‹ nice to meet you", 8); // "hello! "
truncateToCodeUnits("hello! πŸ‘‹ nice to meet you", 9); // "hello! πŸ‘‹"

With a limit of 8 code units, πŸ‘‹ needs 2 code units and only 1 slot remains, so the emoji is dropped entirely rather than split. The result is 7 code units, a valid string that fits the limit. Increasing the limit to 9 allows us to fit the emoji exactly.

Summary

Like I said at the top, I got this wrong for years. The problem was that despite being aware of many Unicode complexities I still treated a string as a sequence of characters, rather than a sequence of code units. Every positional operation, such as length, substring, split, reverse, works on code units instead of the characters you actually see.

Before you slice a string, first ask yourself whether you mean code units, code points, or graphemes?

For anything a human reads, the answer is almost always graphemes.

The grapheme-aware APIs already exist with StringInfo in .NET, Intl.Segmenter in the browser and Node.js. They’re barely more code than the naive version and they don’t mangle emoji.

Hardly anyone reaches for them, which means most codebases are carrying a latent string bug, quietly waiting for the right emoji to set it off.