JavaScript String Manipulation: The Coding Interview Guide

June 18, 202610 min read
dsaalgorithmsinterview-prepleetcode
JavaScript String Manipulation: The Coding Interview Guide
TL;DR
  • Strings are immutable in JavaScript: every method returns a new one, and concatenating with += in a loop is O(n²) worst case
  • .reverse() does not exist on strings — the fix is s.split("").reverse().join("")
  • charCodeAt and fromCharCode are the ASCII tools for letter-to-index mapping and character range checks
  • Two-pointer palindrome check uses O(1) space vs O(n) for the reverse-and-compare approach
  • split("") breaks on emoji and multi-unit Unicode — use spread [...s] when ASCII is not guaranteed
  • Lexicographic sort traps numeric strings["10","9","2"].sort() returns ["10","2","9"] unless you pass a numeric comparator

You think strings are the easy part. You've used them every day for years. How hard can it be?

Then you're 20 minutes into an interview, you type .reverse() on a string, and you watch the TypeError appear in silence while your interviewer waits. Turns out there's a lot happening under the hood with JavaScript strings that nobody thinks about until it costs them.

The methods that actually matter, what they cost, the gotchas that trip people up, and the patterns that win.

Strings Are Immutable. Every Method Returns a New One.

This is the single most important thing to know about JavaScript strings. You cannot modify a string in place. Calling .replace(), .slice(), or .toLowerCase() produces a new string and leaves the original untouched.

let s = "hello"; s[0] = "H"; // silently does nothing console.log(s); // "hello"

No error. No warning. Nothing. JavaScript just ignores you and moves on.

The immutability has a real performance consequence. Building a string by concatenation in a loop looks cheap but is not:

// Each += creates a new string and copies both halves let result = ""; for (const ch of chars) { result += ch; } // Array collect + single join is the safer pattern const parts = []; for (const ch of chars) { parts.push(ch); } const result = parts.join("");

Modern V8 uses a "rope" optimization to defer some copying, so += is faster than it used to be. But when you're building a string character by character across many iterations, the array-then-join pattern is what interviewers expect and is still the right call in practice.

JavaScript String Methods Worth Memorizing

Splitting and Joining

"hello world".split(" ") // ["hello", "world"] "abc".split("") // ["a", "b", "c"] ["a","b","c"].join("") // "abc" ["a","b","c"].join("-") // "a-b-c"

split("") turns a string into a character array, and that unlocks sort, filter, and reverse. Then join back.

const sorted = s.split("").sort().join(""); const reversed = s.split("").reverse().join("");

Slicing

"hello".slice(1, 3) // "el", [start, end), end is exclusive "hello".slice(-2) // "lo", negative counts from the end "hello".slice(2) // "llo", to the end

slice is what you want. substring also exists but ignores negative indices (it treats them as 0), and substr is deprecated. Just use slice. There are three string slicing methods and one of them is correct.

Searching

"hello".indexOf("l") // 2, first occurrence "hello".indexOf("l", 3) // 3, search starting at index 3 "hello".indexOf("x") // -1, not found "hello".includes("ell") // true "hello".startsWith("he") // true "hello".endsWith("lo") // true

All O(n) in the worst case. includes, startsWith, and endsWith return booleans and read more clearly than checking indexOf !== -1. Use them when you don't need the position, your interviewer will appreciate not having to decode the -1 check.

Character Access

"hello"[0] // "h" "hello".at(-1) // "o", negative indices, ES2022 "hello".charCodeAt(0) // 104, UTF-16 code unit String.fromCharCode(104) // "h"

charCodeAt and fromCharCode are your ASCII tools. Use them when you need to check character ranges, shift characters, or map a letter to an array index.

// Is ch a lowercase letter? const code = ch.charCodeAt(0); const isLower = code >= 97 && code <= 122; // Map 'a'-'z' to indices 0-25 const idx = ch.charCodeAt(0) - "a".charCodeAt(0);

Know these ranges: lowercase a-z is 97-122, uppercase A-Z is 65-90, digits 0-9 are 48-57. Write them down on a sticky note. Tattoo them somewhere. Whatever it takes.

Case and Trimming

"Hello".toLowerCase() // "hello" "Hello".toUpperCase() // "HELLO" " hi ".trim() // "hi" " hi ".trimStart() // "hi " " hi ".trimEnd() // " hi"

Replace

"hello".replace("l", "r") // "herlo", first match only "hello".replaceAll("l", "r") // "herro", all occurrences "hello".replace(/l/g, "r") // "herro", regex with global flag

replaceAll landed in ES2021. The /g regex form works everywhere. replace without a global flag only hits the first match, which catches people off guard every single time.

Padding and Repeat

"7".padStart(3, "0") // "007" "7".padEnd(3, "0") // "700" "ab".repeat(3) // "ababab"

The Gotchas That Cost Candidates

.reverse() Does Not Exist on Strings

This is the most common reflexive mistake in string interview problems:

"hello".reverse() // TypeError: s.reverse is not a function

Strings are not arrays. You've been using arrays your whole career and some of it sticks. The fix, which you will now remember forever:

const reversed = s.split("").reverse().join("");

Split into array, reverse the array (which does have .reverse()), join back. Three steps. Every time.

Comparison Is Lexicographic

"10" < "9" // true, "1" comes before "9" in UTF-16 order

When sorting strings that represent numbers, you need a numeric comparator. This burns people on any problem where string-encoded values need numeric ordering.

["10", "9", "2"].sort() // ["10", "2", "9"], wrong ["10", "9", "2"].sort((a, b) => a - b) // ["2", "9", "10"], right

The default sort is alphabetical. Always. Even when that makes no sense.

split("") Breaks on Emoji and Multi-Unit Characters

const s = "𝄞"; // a musical symbol outside the BMP s.split("").length // 2, splits on UTF-16 code units [...s].length // 1, correct Array.from(s).length // 1, also correct

Most interview problems guarantee ASCII input, so split("") is fine. But if the problem involves emoji, usernames, or "any Unicode string," reach for spread. The interviewer who asks about emoji handling is testing whether you know this exists.

Never Use new String()

typeof "hello" // "string" typeof new String("hi") // "object" new String("hi") === new String("hi") // false, reference comparison

String wrapper objects break === comparisons. Use string literals always. Nobody uses new String() on purpose, but now you'll recognize it immediately if you see it.

Template Literals for Short Concatenation

When you're combining a few known pieces, template literals are cleaner than +:

const label = `${key}: ${value}`;

Save the array-join pattern for building strings in loops.

What Each Operation Costs

OperationTime
s[i] or s.at(i)O(1)
s.lengthO(1)
s.slice(i, j)O(j - i)
s.indexOf(sub)O(n)
s.includes(sub)O(n)
s.split("")O(n)
arr.join("")O(n)
+= in a loop (n chars)O(n²) worst case
Array + join (n chars)O(n)
s.split("").sort()O(n log n)

The one that surprises people: += in a loop is O(n²). A hundred characters is fine. A million characters is not. Interviewers ask about this explicitly, so have the answer ready.

The Patterns That Actually Come Up

Anagram Check

Sort approach, O(n log n):

function isAnagram(s, t) { if (s.length !== t.length) return false; const normalize = str => str.split("").sort().join(""); return normalize(s) === normalize(t); }

Frequency count, O(n):

function isAnagram(s, t) { if (s.length !== t.length) return false; const count = new Array(26).fill(0); const base = "a".charCodeAt(0); for (let i = 0; i < s.length; i++) { count[s.charCodeAt(i) - base]++; count[t.charCodeAt(i) - base]--; } return count.every(c => c === 0); }

The frequency approach is faster and uses constant space (26 entries), which is worth mentioning out loud during the interview. Don't just code the better solution quietly. Say why it's better.

Palindrome Check Without Reversing

function isPalindrome(s) { let left = 0, right = s.length - 1; while (left < right) { if (s[left] !== s[right]) return false; left++; right--; } return true; }

Two pointers uses O(1) space. Reversing the string uses O(n). Say that difference out loud. The two-pointer technique applies directly to strings anywhere you're comparing characters from both ends.

Sliding Window on a String

The sliding window pattern maps cleanly to strings. Longest substring without repeating characters:

function lengthOfLongestSubstring(s) { const seen = new Map(); let left = 0, maxLen = 0; for (let right = 0; right < s.length; right++) { const ch = s[right]; if (seen.has(ch) && seen.get(ch) >= left) { left = seen.get(ch) + 1; } seen.set(ch, right); maxLen = Math.max(maxLen, right - left + 1); } return maxLen; }

Character Frequency Map

const freq = new Map(); for (const ch of s) { freq.set(ch, (freq.get(ch) ?? 0) + 1); }

?? (nullish coalescing) is cleaner than || 0 here because 0 is falsy in JS, which can mask a frequency that's already zero. Use ?? and look like you know what you're doing.

What Interviewers Are Actually Noticing

When you manipulate strings in an interview, a few things signal whether you've thought this through.

Do you know that strings are immutable and build with array-join? Candidates who write result += ch in a loop are showing they haven't thought about allocation. Switching to array-join, or at least flagging the tradeoff, is a signal.

Do you reach for charCodeAt for character math? Any problem involving letter shifts, frequency arrays indexed by character, or character validation needs it. Fluency here signals you've worked with these problems before.

Do you use two pointers instead of creating new strings? For palindrome checks and anything meeting-in-the-middle, generating a new reversed string wastes space. The two-pointer pattern stays O(1) and shows stronger instincts.

Do you know when regex is overkill? s.includes("x") is clearer than /x/.test(s) for a simple substring check. Saving regex for pattern-based replacements shows judgment.

These are the kinds of tradeoffs you need to narrate out loud, not just code in silence. SpaceComplexity runs voice-based mock interviews where you practice exactly that, which is closer to the real interview condition than grinding problems on a text editor.

For a broader look at the JavaScript standard library for interviews, see the JavaScript for coding interviews guide. For the bugs that trip people up on string problems specifically, string coding interview bugs walks through the ones that look correct until submission.

Cheat Sheet

// Convert to char array and back s.split("") // string → array arr.join("") // array → string // Slice (supports negative indices) s.slice(start, end) // [start, end), end exclusive // Character access s[i] // O(1), no bounds check s.at(-1) // last char, ES2022 s.charCodeAt(i) // UTF-16 code unit String.fromCharCode(n) // code unit → char // Search s.indexOf(sub) // first index or -1 s.includes(sub) // boolean s.startsWith(pre) // boolean s.endsWith(suf) // boolean // Case / whitespace s.toLowerCase() s.toUpperCase() s.trim() // Replace s.replace(old, new) // first match s.replaceAll(old, new) // all matches (ES2021) // Build a string in a loop const parts = []; for (...) parts.push(piece); const result = parts.join(""); // Reverse (no native method) s.split("").reverse().join("") // ASCII ranges // a-z: 97-122 A-Z: 65-90 0-9: 48-57

Further Reading