Module F-3 — Strings: Immutability and Manipulation Patterns
What this module covers: Strings look like arrays of characters — indexable, iterable, sliceable — but JavaScript enforces one rule arrays don't have: a string can never be changed in place. This module covers what that immutability actually costs, why naive string-building in a loop is a quadratic-time trap nearly identical to the array spread mistake from Module F-2, and the substring/rotation patterns that recur across the rest of this course.
Strings Are Immutable — What That Actually Means
In JavaScript, every string operation that looks like it "modifies" a string actually creates a brand new string and leaves the original untouched.
typescript
This isn't a JavaScript quirk — it's true in Python, Java, and most modern languages. Strings are treated as values, not as mutable buffers, precisely so that two variables can safely reference "the same" string without one's changes leaking into the other. It's also what makes strings safe to use as object keys and Map/Set entries (Module F-4): a key that could silently change out from under you would break every hash-based lookup relying on it.
Analogy: A string is a printed page, not a whiteboard. If you want to change one letter, you don't erase and rewrite in place — you print a whole new page with that one letter changed, and the old page still exists exactly as it was.
Why this matters in production: immutability is a correctness guarantee, but it has a cost that's easy to miss — every "modification" you write is secretly an allocation of new memory, not a cheap in-place edit.
The Concatenation Trap
This is the string equivalent of the array-spread mistake from Module F-2, and it's one of the most common accidental-quadratic bugs in real code:
typescript
Every report += row doesn't append to report in place — it allocates an entirely new string long enough to hold the old content plus the new piece, then copies everything over. On iteration 1,000 of a 10,000-row loop, that copy is already ~1,000 characters of dead work repeated on every subsequent iteration. Summed across the whole loop, that's the same 1 + 2 + 3 + ... + n shape as the array-spread problem: O(n²) overall.
.join() avoids this because the engine can compute the total output length once, allocate a single buffer of exactly that size, and fill it — one allocation instead of thousands. The fix is almost always the same shape: accumulate pieces in an array, join once at the end, rather than concatenating a growing string on every iteration.
Reading Without Modifying: Slicing and Searching
Because strings can't be mutated, every string algorithm you write is really about reading ranges and searching — not editing.
typescript
slice() and indexOf() are both O(n) in the worst case — they may have to scan or copy across the full length of the string. That's not a problem in isolation, but it becomes one the same way array scans do: calling .indexOf() inside a loop over every character turns an O(n) scan into O(n²) overall, exactly like .includes() did for arrays in Module F-1.
split() is worth calling out specifically because it converts an immutable string into a regular, freely mutable array — which is why "turn the string into an array, mutate the array, join it back into a string" is such a common pattern for anything that needs in-place-feeling edits.
Two Classic String Patterns
Palindrome check, using the same two-pointer shape introduced for array reversal in Module F-2 (and covered in full in Module F-8):
typescript
This is O(n) time and O(n) space here (the clean copy), or O(1) extra space if you skip non-alphanumeric characters in place with index math instead of pre-building a cleaned copy — a common follow-up optimization once the basic two-pointer shape is understood.
Rotation check — is s2 some rotation of s1 (e.g. is "lohel" a rotation of "hello")? The brute-force approach tries every possible rotation point, which is O(n²). There's a much shorter path:
typescript
Concatenating s1 with itself produces a string containing every possible rotation of s1 as a contiguous substring. Checking whether s2 appears anywhere in s1 + s1 answers the rotation question in one substring search — O(n) with an efficient search algorithm (Module A-11 covers exactly how .includes()-style substring search can be made linear instead of quadratic, via KMP).
Where This Shows Up in Your Stack
Logging and request tracing: building a log line by concatenating dozens of += fragments per request, at high request volume, adds up to real CPU time spent on string allocation that has nothing to do with the actual work being logged. Template literals (`${a} ${b} ${c}`) compile to a single concatenation rather than several sequential ones, and are almost always preferable to a chain of +=.
Building SQL or query strings: manually concatenating user input into a query string is both a performance anti-pattern (same O(n²) risk if done in a loop) and, far more importantly, the classic vector for SQL injection — Module F-7 of the Node.js course and every ORM's parameterized-query API exist specifically so you never do this by hand.
Node.js Buffer vs string: when you're processing raw bytes (file contents, network payloads) rather than text meant to be read, converting to a JavaScript string and back is extra allocation and encoding/decoding work you don't need — this is why Node's fs and net APIs default to Buffer and only convert to string when you explicitly ask for one encoding or another.
Safe cache keys: because strings are immutable and compared by value, they're the default choice for cache keys (Redis keys, Map keys, memoization keys) — a key that could quietly change after being stored would silently break every lookup depending on it. This immutability guarantee is the same property that makes strings (and not arrays, which are mutable) safe default keys in a Map.
Summary
Strings are immutable. Every operation that looks like a mutation returns a new string; the original is untouched.
Naive concatenation in a loop is O(n²). Each += allocates and copies everything built so far — the fix is accumulating pieces in an array and calling .join() once.
Slicing and searching are O(n) each, and calling them inside a loop compounds into O(n²), the same trap as .includes() on arrays.
split() converts an immutable string into a mutable array — the standard escape hatch for anything that needs repeated in-place-feeling edits.
Two-pointer and concatenation-trick patterns recur: palindrome checking previews Module F-8's two-pointer technique; the rotation check ((s1+s1).includes(s2)) turns an O(n²) brute force into a single O(n) substring search.
The next module, Hash Maps: Hashing, Collisions, and O(1) Lookups, is where the "loop inside a loop" problem that's shown up twice now (Module F-1's duplicate check, this module's naive rotation check) gets its general-purpose fix.
Knowledge Check
Why does building a string with report += row inside a loop over n rows cost O(n²) overall, rather than O(n)?
According to the module, why does (s1 + s1).includes(s2) correctly determine whether s2 is a rotation of s1?
The module states that string immutability is part of why strings are the default safe choice for Map/Set keys and cache keys. What is the reasoning behind that claim?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
let name ='jatin';name[0]='J';// silently does nothing — strings can't be mutated by indexconsole.log(name);// still "jatin"let upper = name.toUpperCase();// creates and returns a NEW stringconsole.log(name);// "jatin" — unchangedconsole.log(upper);// "JATIN" — a different string entirely
// O(n²) — every += allocates a brand new string, copying everything built so farfunctionbuildReportSlow(rows:string[]):string{let report ='';for(const row of rows){ report += row +'\n';// allocates a new string the length of (report so far + row)}return report;}// O(n) — join allocates once, at the end, computing the total length up frontfunctionbuildReportFast(rows:string[]):string{return rows.join('\n');}
const s ='the quick brown fox';s.slice(4,9);// "quick" — a substring from index 4 up to (not including) 9s.indexOf('brown');// 10 — the starting index of the first match, or -1 if absents.split(' ');// ["the", "quick", "brown", "fox"] — an array, safe to mutate freely
functionisPalindrome(s:string):boolean{const clean = s.toLowerCase().replace(/[^a-z0-9]/g,'');// strip punctuation/caselet left =0;let right = clean.length -1;while(left < right){if(clean[left]!== clean[right])returnfalse; left++; right--;}returntrue;}
functionisRotation(s1:string, s2:string):boolean{if(s1.length !== s2.length)returnfalse;return(s1 + s1).includes(s2);// any rotation of s1 is a substring of s1 doubled}