Skip to Content
Course content

121: String Slicing and UTF-8 Boundaries

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a teammate who was building a "preview" snippet for a blog engine. The logic was simple: take the first 100 characters of the post and add an ellipsis. It worked perfectly in local testing with English text. But the moment it hit production and a user posted a review in Japanese—or even just used a single emoji—the entire worker thread panicked and crashed. The error was a classic panic at 'byte index 100 is not a char boundary'. He was devastated because he'd tested it for hours; he just hadn't tested it with non-ASCII characters.

The Byte-Index Trap

In many languages, you can treat a string like an array of characters. In Rust, that's a dangerous assumption. A String or a &str is a sequence of UTF-8 encoded bytes. While an ASCII character like 'a' takes up exactly one byte, a character like '🦀' takes up four.

When you slice a string using the &s[start..end] syntax, you aren't telling Rust to give you the 1st through 10th characters; you're telling it to give you the 1st through 10th bytes. If your range ends in the middle of a multi-byte character, Rust refuses to give you an invalid UTF-8 sequence and panics immediately. I've always found this frustrating at first, but it's actually Rust saving you from creating "mojibake"—that garbled text you see when encoding goes wrong.

let heart_eye = "😍 Rust";
// This looks like it should work to get the emoji, 
// but "😍" is 4 bytes long.
let slice = &heart_eye[0..1]; // 💥 PANIC: byte index 1 is not a char boundary

Navigating Characters Safely

If you need to slice a string based on the actual characters (what we call "Unicode Scalar Values"), you have to stop thinking in terms of indices and start thinking in terms of iterators. The .chars() method is your best friend here. It lets you step through the string one character at a time, regardless of how many bytes that character occupies.

However, if you actually need the byte indices to perform a slice—perhaps for performance reasons—you should use .char_indices(). This returns a tuple containing the byte position and the character itself. By iterating through this, you can find the exact byte boundary where a character ends.

Here is how I usually handle safe truncation. Instead of guessing the index, I use the iterator to find the boundary:

let text = "🦀 Rustacean";
let max_chars = 4;

let end_index = text
    .char_indices()
    .nth(max_chars)
    .map(|(idx, _)| idx)
    .unwrap_or(text.len());

let safe_slice = &text[..end_index];
println!("{}", safe_slice); // Prints "🦀 Ru"

Notice what happened there: I didn't just add 4 to the index. I asked the iterator to find the byte position of the 4th character. If the string is shorter than the limit, unwrap_or(text.len()) ensures we just take the whole thing without crashing. It's a bit more verbose than a simple array slice, but it's the only way to sleep soundly at night when your users are global.




📋 Practical Task

Building a UTF-8 Aware User Bio Truncator

You are building a social media profile page. Users can enter a bio of any length, but the UI only has room for a short preview. You need to write a function that truncates a string to a specific number of characters and appends "..." if it was truncated, without ever crashing on multi-byte characters.

Requirements:

  • Create a function truncate_bio(bio: &str, max_chars: usize) -> String.
  • The function must count characters, not bytes.
  • If the bio is longer than max_chars, truncate it and append "...".
  • If the bio is shorter than or equal to max_chars, return it as-is.
  • Test your function with the following input: "Rust is 🦀-tastic!" with a max_chars of 10. It should output "Rust is 🦀-..." (Note: the emoji counts as one character).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.