String Manipulation in Dart
This is Part 8 of the Dart Fundamentals series. We already covered the basics of strings — literals, quotes, $interpolation, multi-line and raw strings — back in Part 3. So this post deliberately skips all that and dives into the part you actually spend your time on: transforming strings. Searching, slicing, splitting, replacing, cleaning, and pattern-matching.
One thing to keep in the back of your mind throughout: strings are immutable. Every method here returns a brand new string and leaves the original untouched.
var s = 'hello';
s.toUpperCase(); // returns 'HELLO'...
print(s); // ...but s is still 'hello'
s = s.toUpperCase(); // you must reassign to "change" it
Inspecting a string
The first questions you ask about a string — how long, is it empty, what's at index N:
var s = 'Dart';
s.length; // 4
s.isEmpty; // false
s.isNotEmpty; // true
s[0]; // 'D' (index access, like a list)
s.codeUnitAt(0); // 68 (the UTF-16 code unit)
Reminder from Part 3:
.lengthand[index]count UTF-16 code units, not human-perceived characters. For emoji and accented text, use thecharacterspackage — more on that at the end.
Searching: contains, indexOf, startsWith, endsWith
var path = 'images/photo.png';
path.contains('photo'); // true
path.startsWith('images'); // true
path.endsWith('.png'); // true
path.indexOf('.'); // 12 (first match, or -1 if absent)
path.lastIndexOf('.'); // 12 (last match)
contains is your everyday "is X in here?" indexOf returns the position (or -1 when not found) — handy when you need where, not just whether.
Slicing with substring
Extract a portion by index. The start is inclusive, the end is exclusive (just like list ranges):
var s = 'Hello, World';
s.substring(7); // 'World' — from index 7 to the end
s.substring(0, 5); // 'Hello' — index 0 up to (not incl.) 5
s.substring(7, 12); // 'World'
Combine it with indexOf to slice around a delimiter — e.g. grab a file extension:
var file = 'report.final.pdf';
var ext = file.substring(file.lastIndexOf('.') + 1); // 'pdf'
Changing case and trimming whitespace
'hello'.toUpperCase(); // 'HELLO'
'HELLO'.toLowerCase(); // 'hello'
' spaced '.trim(); // 'spaced' — both ends
' spaced '.trimLeft(); // 'spaced ' — left only
' spaced '.trimRight(); // ' spaced' — right only
trim() is the unsung hero of form handling — always trim user input before validating it, or a stray space will ruin your day.
Padding and repeating
Useful for aligning output, formatting numbers, building simple tables:
'7'.padLeft(3, '0'); // '007' — pad to width 3 with '0'
'7'.padRight(3, '.'); // '7..'
'ab' * 3; // 'ababab' — repeat with the * operator
padLeft(width, char) pads the start until the string reaches width; padRight pads the end. The fill char defaults to a space.
Splitting and joining
split turns a string into a List<String> on a delimiter; join does the reverse on any iterable:
var csv = 'apple,banana,cherry';
var fruits = csv.split(','); // ['apple', 'banana', 'cherry']
fruits.join(' | '); // 'apple | banana | cherry'
Split on an empty string to get individual characters (UTF-16-wise), and recombine after transforming:
'dart'.split('').reversed.join(); // 'trad'
This split → transform → join pipeline is one of the most common string idioms there is.
Replacing
var s = 'I like cats. Cats are great.';
s.replaceAll('cats', 'dogs'); // replaces every (case-sensitive) match
s.replaceFirst('Cats', 'Dogs'); // only the first match
replaceAll swaps every occurrence; replaceFirst only the first. Both are case-sensitive — which is exactly where regular expressions come in.
Regular expressions with RegExp
When a plain String match isn't expressive enough — "any digit," "case-insensitive," "a word boundary" — you reach for RegExp. Every search/replace method that accepts a String pattern also accepts a RegExp:
var text = 'Order 123, item 456, qty 7';
var digits = RegExp(r'\d+'); // one or more digits; r'' = raw string
text.contains(digits); // true
text.replaceAll(digits, '#'); // 'Order #, item #, qty #'
// pull out every match
for (final m in digits.allMatches(text)) {
print(m.group(0)); // 123, then 456, then 7
}
Note the r'\d+' — a raw string (from Part 3) so the backslash reaches the regex engine instead of being interpreted by Dart first. Always write regex patterns as raw strings.
A case-insensitive example:
var ci = RegExp('cats', caseSensitive: false);
'Cats and cats'.replaceAll(ci, 'dogs'); // 'dogs and dogs'
And capturing groups, to extract structured pieces:
var datePattern = RegExp(r'(\d{4})-(\d{2})-(\d{2})');
var match = datePattern.firstMatch('Today is 2026-07-06.');
match?.group(1); // '2026' (year)
match?.group(2); // '07' (month)
match?.group(3); // '06' (day)
(firstMatch returns a nullable RegExpMatch? — null when there's no match — so we use ?. from Part 5.)
StringBuffer — building big strings efficiently
Because strings are immutable, concatenating in a loop creates a new string every iteration — fine for a few joins, wasteful for thousands:
// ❌ allocates a new string on every iteration
var result = '';
for (var i = 0; i < 10000; i++) {
result += 'line $i\n';
}
StringBuffer accumulates into a mutable buffer and produces the final string just once, at the end:
// ✅ one allocation at the end
var buffer = StringBuffer();
for (var i = 0; i < 10000; i++) {
buffer.write('line $i');
buffer.writeln(); // write + newline
}
var result = buffer.toString();
Useful members: write(obj), writeln([obj]), writeAll(iterable, [separator]), .length, and .clear(). Rule of thumb: building a string in a loop? Use a StringBuffer.
A word on Unicode-correct manipulation
Everything above works on UTF-16 code units. For plain ASCII that's perfectly fine. But the moment user text contains emoji, flags, or combined accents, code-unit operations can split a character in half (we saw this in Part 3). For user-facing reversing, truncating, or counting, use the characters package, which works in grapheme clusters:
import 'package:characters/characters.dart';
var s = 'café 👍🏽';
s.length; // counts code units — misleading
s.characters.length; // counts real characters — what humans see
s.characters.takeLast(1); // the whole 👍🏽, never a broken half
Default to ordinary String methods for internal/ASCII work; switch to .characters whenever real human text is involved.
Practice Challenges
Try first, then check.
Challenge 1 — Initials. Given var name = 'ada lovelace';, produce 'A.L.'.
Show solution
var name = 'ada lovelace';
var initials = name
.split(' ')
.map((word) => word[0].toUpperCase())
.join('.');
initials = '$initials.'; // trailing dot
// → 'A.L.'
Split into words, take the first letter of each, uppercase it, join with dots. A clean split→map→join.
Challenge 2 — Clean a username. Trim whitespace and lowercase ' HelloWorld '.
Show solution
' HelloWorld '.trim().toLowerCase(); // 'helloworld'
Method chaining: trim() then toLowerCase(). Always trim user input first.
Challenge 3 — Zero-pad an ID. Turn the int 42 into the string '0042' (width 4).
Show solution
42.toString().padLeft(4, '0'); // '0042'
Convert to a string, then padLeft(4, '0'). (Recall toString() from Part 3.)
Challenge 4 — Count the vowels. Count vowels in 'Dartlang' (case-insensitive).
Show solution
var word = 'Dartlang';
var count = RegExp(r'[aeiou]', caseSensitive: false)
.allMatches(word)
.length;
// → 2 (a, a)
A character-class regex plus .allMatches(...).length. You could also filter with where, but regex is tidy here.
Challenge 5 — Mask an email. Turn 'asha@example.com' into 'a***@example.com' (keep first char of the local part, mask the rest before @).
Show solution
var email = 'asha@example.com';
var at = email.indexOf('@');
var local = email.substring(0, at);
var masked = local[0] + '*' * (local.length - 1);
var result = masked + email.substring(at); // 'a***@example.com'
indexOf('@') finds the split point; substring slices the local part and the domain; '*' * n builds the mask.
Challenge 6 — Efficient builder. Build the string "1, 2, 3, ..., 100" using a StringBuffer.
Show solution
var buffer = StringBuffer();
for (var i = 1; i <= 100; i++) {
buffer.write(i);
if (i < 100) buffer.write(', ');
}
print(buffer.toString()); // 1, 2, 3, ..., 100
StringBuffer avoids re-allocating the growing string on each iteration. (You could also do [for (var i = 1; i <= 100; i++) i].join(', ') using collection-for from Part 6 — both are idiomatic.)
Check Yourself (Q&A)
Q1. Are Dart strings mutable? No. Every method returns a new string; the original is unchanged. Reassign the variable to "modify" it.
Q2. replaceAll vs replaceFirst?
replaceAll swaps every occurrence; replaceFirst only the first. Both are case-sensitive unless you pass a case-insensitive RegExp.
Q3. Why write regex patterns as raw strings (r'...')?
So Dart doesn't interpret backslash escapes before the regex engine sees them. r'\d+' passes \d+ through intact; '\d+' would mangle it.
Q4. When should I use StringBuffer?
When building a string across many iterations. Repeated += on a String allocates a new string each time; StringBuffer accumulates and produces the result once.
Q5. What does firstMatch return when nothing matches?
null (its type is RegExpMatch?), so guard it with ?. or a null check before reading groups.
Q6. When do I need the characters package?
Whenever you manipulate user-facing text with emoji/flags/accents — counting, reversing, truncating. Plain String methods count UTF-16 units and can split such characters in half.
Wrapping Up
- Strings are immutable — methods return new strings.
- Search with
contains/indexOf/startsWith/endsWith; slice withsubstring. - Clean with
trim/toLowerCase; align withpadLeft/padRight; transform withsplit→map→join. - Replace with
replaceAll/replaceFirst, escalating toRegExp(always raw strings) for patterns, case-insensitivity, and capture groups. - Use
StringBufferfor heavy/loop string building, and thecharacterspackage for Unicode-correct user text.
Next, in Part 9, we wrap up the language core with control flow — if, switch, loops, and Dart 3's powerful pattern matching and switch expressions.