TypeScript String Manipulation for Coding Interviews: The Complete Guide

- Immutability cost: String concatenation in a loop is O(n²); collect chars into an array and call
.join("")for O(n) instead. sliceoversubstring: Negative indices work predictably withslice;substringauto-swaps args and treats negatives as 0.- Frequency pattern: A 26-element array with
charCodeAt(i) - "a".charCodeAt(0)solves all ASCII anagram and frequency problems in O(n). - Unicode safety: Use
Array.from(s)andcodePointAtfor emoji;s.split("")andcharCodeAtsplit multi-code-unit characters incorrectly. indexOfreturns -1, not null:if (s.indexOf("foo"))silently fails when the match is at index 0; use!== -1orincludes.- Sliding window template: Two pointers plus a Map or frequency array handles most "longest/shortest substring with constraint" problems in O(n).
- Comparison uses
===: No.equals()needed; direct equality works for TypeScript strings, unlike Java.
TypeScript string manipulation shows up in roughly a third of all coding interviews. Anagrams, sliding windows, palindromes, pattern matching. All of it. TypeScript's built-ins are solid, but most candidates who fumble these don't fumble because they forgot an algorithm. They fumble because strings aren't what they think they are. This is the reference.
Strings Are Immutable. Your Loop Isn't Free.
The most important thing to understand before touching any string problem: every string operation that produces a new string allocates new memory. TypeScript strings are immutable primitives. You cannot change a character in place.
let s = "hello"; s[0] = "H"; // silently does nothing (throws in strict mode) console.log(s); // "hello"
This surprises basically everyone the first time. JavaScript was designed by someone who really liked hiding things.
The consequence for interviews is direct. String concatenation inside a loop is O(n²).
// O(n²), don't do this let result = ""; for (const part of parts) { result += part; } // O(n), do this instead return parts.join("");
The fix is boring but required: collect characters into an array, then call .join("") once at the end. If an interviewer asks about complexity and you have concatenation in a loop, they will notice. They won't say anything. Their face just does something.
The Methods You Actually Need
Most interview string work comes down to about fifteen methods.
| Method | What it does | Time |
|---|---|---|
s.length | Character count | O(1) |
s[i] / s.charAt(i) | Character at index | O(1) |
s.charCodeAt(i) | UTF-16 code unit at index | O(1) |
String.fromCharCode(n) | Character from code unit | O(1) |
s.slice(start, end) | Substring (negative indices count from end) | O(k) |
s.indexOf(sub) | First occurrence index, -1 if missing | O(n·m) |
s.includes(sub) | Boolean substring check | O(n·m) |
s.startsWith(prefix) | Prefix check | O(m) |
s.endsWith(suffix) | Suffix check | O(m) |
s.split(delim) | Array of substrings | O(n) |
s.toLowerCase() / .toUpperCase() | Case conversion | O(n) |
s.trim() | Remove leading/trailing whitespace | O(n) |
s.repeat(n) | Concatenate n copies | O(n·k) |
s.replaceAll(old, replacement) | Replace all matches | O(n) |
Array.from(s) | Array of characters (Unicode-safe) | O(n) |
String comparison uses === directly. No .equals() needed, unlike Java. This trips up candidates switching languages mid-prep season.
Character Operations: Everything Else Builds On These
Anagram detection, character frequency, cipher shifts. They all share the same foundation. You need charCodeAt to convert a character to a number and String.fromCharCode to go back.
const code = "a".charCodeAt(0); // 97 const offset = "e".charCodeAt(0) - "a".charCodeAt(0); // 4 const char = String.fromCharCode(97 + 4); // "e"
The standard frequency pattern uses a 26-element array for lowercase ASCII problems:
function charFrequency(s: string): number[] { const freq = new Array(26).fill(0); const aCode = "a".charCodeAt(0); for (const ch of s) { freq[ch.charCodeAt(0) - aCode]++; } return freq; }
When the character set isn't constrained to a-z, use a Map<string, number> instead:
function charFrequency(s: string): Map<string, number> { const freq = new Map<string, number>(); for (const ch of s) { freq.set(ch, (freq.get(ch) ?? 0) + 1); } return freq; }
slice Over substring. Always.
Both extract substrings. The difference only matters when indices are negative or swapped, which happens more than you'd expect in interview code under time pressure.
const s = "hello"; s.slice(1, 3); // "el" s.slice(-2); // "lo" s.slice(-3, -1); // "ll" s.substring(-2); // "hello" (treats -2 as 0) s.substring(3, 1); // "el" (auto-swaps args)
Default to slice. The negative-index behavior is consistent and predictable. substring's auto-swapping is a footgun: your index arithmetic looks correct and silently produces wrong results. You'll stare at it for ten minutes wondering if you've forgotten arithmetic. Avoid substr(start, length) entirely. It's deprecated, and it takes a length instead of an end index, which is its own flavor of confusion.
Anagrams: Know Both or Expect a Follow-Up
Two approaches, two complexities. Sort is cleaner to read on a whiteboard. Frequency is better.
// O(n log n), readable function isAnagram(s: string, t: string): boolean { if (s.length !== t.length) return false; return s.split("").sort().join("") === t.split("").sort().join(""); } // O(n), better for large inputs function isAnagram(s: string, t: string): boolean { if (s.length !== t.length) return false; const freq = new Array(26).fill(0); const a = "a".charCodeAt(0); for (let i = 0; i < s.length; i++) { freq[s.charCodeAt(i) - a]++; freq[t.charCodeAt(i) - a]--; } return freq.every(n => n === 0); }
Know both. The interviewer will ask about the trade-off and they will know from your answer whether you understand the O(n log n) sort overhead or are just hoping they don't notice.
Sliding Window: Expand Right, Shrink Left
Sliding window problems on strings follow a tight pattern: two pointers, one frequency structure, one constraint.
// Longest substring without repeating characters function lengthOfLongestSubstring(s: string): number { const seen = new Map<string, number>(); // char -> last seen index let left = 0; let 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; }
The window stores last-seen index when the constraint is uniqueness. For at-most-k-distinct problems, switch to a count map and decrement as the left pointer moves. See Sliding Window Algorithm for the full pattern breakdown.
Palindromes Are Two Pointers From Both Ends
function isPalindrome(s: string): boolean { let left = 0; let right = s.length - 1; while (left < right) { if (s[left] !== s[right]) return false; left++; right--; } return true; }
For the variant that ignores non-alphanumeric characters:
function isAlphanumeric(ch: string): boolean { return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9"); } function isPalindrome(s: string): boolean { let left = 0; let right = s.length - 1; while (left < right) { while (left < right && !isAlphanumeric(s[left])) left++; while (left < right && !isAlphanumeric(s[right])) right--; if (s[left].toLowerCase() !== s[right].toLowerCase()) return false; left++; right--; } return true; }
The Unicode Trap: charCodeAt vs codePointAt
For ASCII-only problems (a-z, A-Z, 0-9), charCodeAt is fine. But with arbitrary Unicode, charCodeAt splits emoji into their two UTF-16 surrogate halves.
const emoji = "👍"; emoji.length; // 2 (two UTF-16 code units) emoji.charCodeAt(0); // 55357 (high surrogate, not the real code point) emoji.codePointAt(0); // 128077 (correct) emoji.split("").length; // 2, wrong Array.from(emoji).length; // 1, correct
A thumbs-up emoji is technically length 2 in JavaScript. Not because it's two characters, but because UTF-16 uses two 16-bit code units to represent it and JavaScript counted code units instead of asking what you wanted. Nobody was thrilled about this decision, but here we are.
When the problem specifies ASCII input, don't worry about any of this. When it says "any string," use Array.from(s) for iteration and codePointAt for code points. Otherwise you'll hand back two garbage characters where an emoji used to be.
Three Things That Burn Candidates
indexOf returns -1, not null. Index 0 is falsy, so if (s.indexOf("foo")) silently fails when the match is at position 0. This is a classic. Every JavaScript developer has been burned by this. The fix is one extra token.
// Bug: fails when "foo" is at index 0 if (s.indexOf("foo")) { ... } // Correct if (s.indexOf("foo") !== -1) { ... } if (s.includes("foo")) { ... }
s.split("") vs s.split(). No argument returns [s] as a single-element array. Empty string splits into individual characters. Easy to mix up when you're moving fast and vaguely remembering which form you want.
Sort comparators need numbers, not booleans. Use a.length - b.length || a.localeCompare(b) for length-then-lexicographic. localeCompare handles the comparator contract cleanly. See TypeScript Sorting and Custom Comparators for the full treatment.
Read the Signal, Pick Your Weapon
| Problem signal | Reach for |
|---|---|
| "anagram" / "same characters" | Frequency array (26 ints) or Map<string, number> |
| "longest substring with constraint" | Sliding window with two pointers |
| "palindrome" | Two pointers from both ends |
| "all permutations" / "find all substrings" | Backtracking or sliding window |
| "reverse words" | .split(" ").reverse().join(" ") |
| "most frequent character" | Frequency map + max tracking |
| Building result from many pieces | Array of chars + .join("") |
| Character shift (Caesar cipher) | charCodeAt + modulo + fromCharCode |
TypeScript String Manipulation: Quick Reference
// Frequency map (ASCII lowercase) const freq = new Array(26).fill(0); s.charCodeAt(i) - "a".charCodeAt(0); // index 0-25 // Sort characters s.split("").sort().join(""); // Reverse s.split("").reverse().join(""); // Check char type ch >= "a" && ch <= "z" // lowercase letter ch >= "0" && ch <= "9" // digit // Build string from array (not concatenation in a loop) const buf: string[] = []; buf.push(ch); const result = buf.join(""); // Sliding window skeleton let left = 0; for (let right = 0; right < s.length; right++) { // expand window with s[right] while (/* window invalid */) { left++; } // window is valid, update answer }
If you want to put these patterns under actual interview conditions, time pressure included, SpaceComplexity runs voice-based mock interviews that score exactly the kind of performance that gets you hired. Not just whether your code compiles.
Further Reading
- MDN: String, JavaScript, Complete method reference with browser compatibility
- ECMAScript 2024 Language Specification, String Objects, The authoritative spec for all string behavior
- Unicode Technical Standard #18: Unicode Regular Expressions, How Unicode works in regex and string matching
- V8 Blog: String representation, How V8 stores strings internally (rope optimization, seq vs cons strings)