Emmanuel Akinfulubi

Lesson 03 of 2412 min read

Strings

Strings are immutable in JavaScript, which changes how you build them and why the naive concat-in-a-loop is a trap interviewers watch for.

By the end: Manipulate strings without accidentally writing O(n squared) code.

A string is a sequence of characters, and in JavaScript it is immutable: you can never change one. Every operation that looks like it edits a string actually builds a new one.

Strings cannot be changed, only replaced

let s = 'hello';

// In strict mode, which is every module and every panel here, this throws.
// In an old sloppy-mode script it was ignored in silence, which was worse.
try {
  s[0] = 'H';
} catch (err) {
  console.log(err.name + ':', err.message);
}
console.log('s is unchanged:', s);

s = 'H' + s.slice(1);             // a brand new string
console.log('after rebuilding:', s);

console.log('length:', s.length, '| char at 1:', s[1], '| charAt:', s.charAt(1));

Output


      

Concat in a loop versus array and join

function buildBad(n) {
  let out = '';
  let charsCopied = 0;
  for (let i = 0; i < n; i++) {
    charsCopied += out.length;     // the whole string is copied each time
    out += 'x';
  }
  return { length: out.length, charsCopied };
}

function buildGood(n) {
  const parts = [];
  for (let i = 0; i < n; i++) parts.push('x');
  return { length: parts.join('').length, charsCopied: n };
}

for (const n of [100, 400, 800]) {
  console.log('n =', String(n).padStart(3),
    '| concat copies:', String(buildBad(n).charsCopied).padStart(7),
    '| join copies:', buildGood(n).charsCopied);
}

Output


      

The operations you will actually use

Slicing, searching, splitting, joining

const text = 'the quick brown fox';

console.log('slice(4, 9):     ', text.slice(4, 9));
console.log('slice(-3):       ', text.slice(-3));
console.log('includes:        ', text.includes('brown'));
console.log('indexOf:         ', text.indexOf('brown'));
console.log('startsWith:      ', text.startsWith('the'));
console.log('split(" "):      ', text.split(' '));
console.log('join back:       ', text.split(' ').join('-'));
console.log('toUpperCase:     ', text.toUpperCase());
console.log('trim:            ', '  padded  '.trim() + '|');
console.log('repeat:          ', 'ab'.repeat(3));
console.log('padStart:        ', '7'.padStart(3, '0'));
console.log('replaceAll:      ', text.replaceAll('o', '0'));

Output


      

Characters and codes

Interview problems frequently need "is this a letter", "what is the 0-based position of this letter in the alphabet", or a 26-slot counting array. All of that runs through character codes.

charCodeAt and the alphabet index

console.log("'a' code:", 'a'.charCodeAt(0), "| 'z' code:", 'z'.charCodeAt(0));
console.log("'A' code:", 'A'.charCodeAt(0), "| '0' code:", '0'.charCodeAt(0));

// The alphabet index trick: 0 for 'a', 25 for 'z'.
const idx = (ch) => ch.charCodeAt(0) - 97;
console.log('index of c:', idx('c'), '| index of z:', idx('z'));

// And back again.
console.log('code 97 is:', String.fromCharCode(97), '| 65 is:', String.fromCharCode(65));

// A 26-slot counter, the standard tool for anagram questions.
function letterCounts(word) {
  const counts = new Array(26).fill(0);
  for (const ch of word) counts[idx(ch)]++;
  return counts;
}
console.log('counts for "banana":', letterCounts('banana').filter(c => c > 0), '(a=3, b=1, n=2)');

Output


      

Classifying characters without a regex

const isDigit  = (ch) => ch >= '0' && ch <= '9';
const isLower  = (ch) => ch >= 'a' && ch <= 'z';
const isUpper  = (ch) => ch >= 'A' && ch <= 'Z';
const isLetter = (ch) => isLower(ch) || isUpper(ch);
const isAlnum  = (ch) => isLetter(ch) || isDigit(ch);

for (const ch of ['7', 'k', 'K', '!', ' ']) {
  console.log(JSON.stringify(ch), '→ digit:', isDigit(ch), 'letter:', isLetter(ch), 'alnum:', isAlnum(ch));
}

Output


      

Two patterns that cover most string questions

Normalise, then compare. Most "is this a palindrome" or "are these anagrams" questions are really about cleaning the input first.

Palindrome, two ways

// The readable way: normalise, reverse, compare. O(n) time, O(n) space.
function isPalindromeSimple(s) {
  const clean = s.toLowerCase().replace(/[^a-z0-9]/g, '');
  return clean === [...clean].reverse().join('');
}

// The interview way: two pointers, no extra string. O(n) time, O(1) space.
function isPalindromeTwoPointer(s) {
  const ok = (ch) => /[a-z0-9]/.test(ch);
  let i = 0, j = s.length - 1;
  const low = s.toLowerCase();
  while (i < j) {
    while (i < j && !ok(low[i])) i++;
    while (i < j && !ok(low[j])) j--;
    if (low[i] !== low[j]) return false;
    i++; j--;
  }
  return true;
}

for (const s of ['A man, a plan, a canal: Panama', 'race a car', '', '.,']) {
  console.log(JSON.stringify(s.slice(0, 22)).padEnd(26),
    'simple:', isPalindromeSimple(s), '| two-pointer:', isPalindromeTwoPointer(s));
}

Output


      

Use a canonical form as a key. If two strings should be treated as equal under some rule, compute a form that is identical for both and use it as a map key.

Grouping anagrams by a canonical key

function groupAnagrams(words) {
  const groups = new Map();
  for (const word of words) {
    // Sorted letters is the canonical form: 'eat' and 'tea' both become 'aet'.
    const key = [...word].sort().join('');
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(word);
  }
  return [...groups.values()];
}

console.log(groupAnagrams(['eat', 'tea', 'tan', 'ate', 'nat', 'bat']));

// A counting key avoids the sort: O(n * k) instead of O(n * k log k).
function groupAnagramsCounting(words) {
  const groups = new Map();
  for (const word of words) {
    const counts = new Array(26).fill(0);
    for (const ch of word) counts[ch.charCodeAt(0) - 97]++;
    const key = counts.join(',');
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(word);
  }
  return [...groups.values()];
}
console.log(groupAnagramsCounting(['eat', 'tea', 'tan', 'ate', 'nat', 'bat']));

Output


      

Unicode, briefly

Almost every interview problem is plain ASCII, so you can ignore this. But you should know it exists, because being able to name the issue is a small credibility win.

Some characters take two slots

const emoji = 'a\u{1F600}b';        // a, a grinning face, b

console.log('length says:', emoji.length, 'but there are 3 characters');
console.log('emoji[1] is half a character:', JSON.stringify(emoji[1]));
console.log('spread splits correctly:', [...emoji], '→', [...emoji].length);
console.log('so reverse via spread is safe:', [...emoji].reverse().join(''));
console.log('codePointAt:', emoji.codePointAt(1).toString(16));

Output


      

Your turn: longest common prefix

Write longestCommonPrefix(words) returning the longest string that starts every word in the array. ['flower','flow','flight'] gives 'fl'; ['dog','racecar','car'] gives ''. Handle an empty array.

Aim for O(total characters) time and O(1) extra space beyond the answer. The simplest correct approach walks character position 0, 1, 2... and stops as soon as any word disagrees or runs out.

Editable. Fill it in, then run.

Output


      

Recap

  • Strings are immutable: every apparent edit builds a new string.
  • Never build a string with += in a loop; push pieces into an array and join('') once.
  • charCodeAt(0) - 97 gives the 0-based alphabet index, which powers 26-slot counting arrays.
  • Comparison is by character code, so 'Z' < 'a' and '10' < '9' are both true.
  • Two patterns solve most string questions: normalise then compare, and use a canonical form as a map key.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.