Skip to main content

Command Palette

Search for a command to run...

String Methods and Polyfills in JavaScript: Logic Behind the Built-Ins

Published
14 min read
String Methods and Polyfills in JavaScript: Logic Behind the Built-Ins

JavaScript ships with a rich set of string methods. Most developers use them every day without thinking much about what is happening underneath. That works fine — until an interviewer asks you to implement one from scratch, or until you need to support an environment where a method does not exist yet.

This article covers both situations. You will understand how common string methods work conceptually, implement polyfills for them, and work through the string problems that show up most often in technical interviews.


What String Methods Are

String methods are functions built into JavaScript's String.prototype that let you manipulate, search, transform, and inspect strings. Because every string in JavaScript inherits from String.prototype, any method on that prototype is available on every string.

const str = "hello world";

str.toUpperCase();   // "HELLO WORLD"
str.includes("world"); // true
str.split(" ");      // ["hello", "world"]
str.trim();          // "hello world" (no change here — no whitespace)

These methods do not modify the original string. Strings in JavaScript are immutable — every operation returns a new string. That is an important detail when you implement your own versions.


Why Developers Write Polyfills

A polyfill is a piece of code that implements a feature that the current environment does not natively support — typically an older browser or an older version of Node.js.

The idea is simple: check whether the method already exists. If it does not, add it.

if (!String.prototype.includes) {
  String.prototype.includes = function(search, start) {
    // your implementation here
  };
}

There are three reasons to understand polyfills even if you never have to ship one:

Legacy environments still exist. Enterprise applications and older mobile browsers sometimes cannot be updated. When a product needs to support IE11, or an embedded system running an old JavaScript engine, polyfills fill the gap between what the language offers now and what the environment supports.

Interviews test them constantly. "Implement Array.prototype.map from scratch" is a standard interview question. The same pattern applies to string methods: "Write your own includes," "Implement startsWith," "Build a trim function." These questions test whether you understand the contract of the method, not just its name.

They make the built-in behaviour concrete. When you try to implement repeat("ab", 3) yourself, you immediately think about edge cases: what if count is 0? What if it is negative? What if the string is empty? Understanding the edge cases is understanding the method.


Implementing String Utilities

For each utility, the approach is the same: state what the method does in plain English, then translate that description directly into code. The built-in implementation is optimised — yours just needs to be correct.


includes — does the string contain this substring?

What it does: Returns true if the search string appears anywhere in the source string, false otherwise. Optionally accepts a starting position.

"hello world".includes("world"); // true
"hello world".includes("xyz");   // false
"hello world".includes("o", 7);  // false — 'o' before position 7

Conceptual logic: Walk through the string starting from start. At each position, check whether the characters from that position match the search string, character by character. If you find a complete match, return true. If you exhaust the string, return false.

Implementation:

String.prototype.myIncludes = function(search, start = 0) {
  const str = String(this);

  if (search.length === 0) return true;
  if (start < 0) start = 0;
  if (start + search.length > str.length) return false;

  for (let i = start; i <= str.length - search.length; i++) {
    let match = true;
    for (let j = 0; j < search.length; j++) {
      if (str[i + j] !== search[j]) {
        match = false;
        break;
      }
    }
    if (match) return true;
  }

  return false;
};

"hello world".myIncludes("world"); // true
"hello world".myIncludes("xyz");   // false

What this reveals: The built-in includes uses a substring search algorithm. The naive approach above runs in O(n × m) time — for each position in the string, it compares up to m characters. More advanced algorithms like Boyer-Moore or KMP do better, but for an interview, the naive version demonstrates the concept correctly.


startsWith — does the string begin with this prefix?

What it does: Returns true if the string starts with the given prefix, starting from an optional position.

"hello world".startsWith("hello"); // true
"hello world".startsWith("world"); // false
"hello world".startsWith("world", 6); // true

Conceptual logic: Starting at the given position, compare each character of the prefix against the corresponding character in the source string. If all characters match, the string starts with the prefix.

Implementation:

String.prototype.myStartsWith = function(prefix, position = 0) {
  const str = String(this);

  if (position < 0) position = 0;
  if (position + prefix.length > str.length) return false;

  for (let i = 0; i < prefix.length; i++) {
    if (str[position + i] !== prefix[i]) return false;
  }

  return true;
};

"hello world".myStartsWith("hello"); // true
"hello world".myStartsWith("world", 6); // true

Edge cases to handle: An empty prefix always returns true (every string starts with nothing). A position beyond the string's length returns false. A negative position is treated as 0.


endsWith — does the string end with this suffix?

What it does: Returns true if the string ends with the given suffix. Optionally accepts an end position to treat as the effective end of the string.

"hello world".endsWith("world"); // true
"hello world".endsWith("hello"); // false
"hello world".endsWith("hello", 5); // true — treat only "hello" as the string

Conceptual logic: Calculate the effective end of the string. From that point, count backward by the length of the suffix and compare characters.

Implementation:

String.prototype.myEndsWith = function(suffix, endPos) {
  const str = String(this);
  const end = endPos === undefined ? str.length : Math.min(endPos, str.length);

  if (suffix.length === 0) return true;
  if (suffix.length > end) return false;

  const start = end - suffix.length;

  for (let i = 0; i < suffix.length; i++) {
    if (str[start + i] !== suffix[i]) return false;
  }

  return true;
};

"hello world".myEndsWith("world"); // true
"hello world".myEndsWith("hello", 5); // true

repeat — repeat the string n times

What it does: Returns a new string consisting of the original string concatenated with itself count times.

"ab".repeat(3);  // "ababab"
"ab".repeat(0);  // ""
"ab".repeat(1);  // "ab"

Conceptual logic: Loop count times, appending the string to a result on each iteration. Return the result.

Implementation:

String.prototype.myRepeat = function(count) {
  const str = String(this);

  if (count < 0) throw new RangeError("repeat count must be non-negative");
  if (!isFinite(count)) throw new RangeError("repeat count must be finite");

  count = Math.floor(count);
  let result = "";

  for (let i = 0; i < count; i++) {
    result += str;
  }

  return result;
};

"ab".myRepeat(3); // "ababab"
"ab".myRepeat(0); // ""

A faster approach: For large counts, you can use a halving technique — double the string and the count, building the result in O(log n) steps instead of O(n). The interview question often asks whether you can optimise beyond the naive loop.

function repeatFast(str, count) {
  if (count === 0) return "";
  let result = "";
  let base = str;

  while (count > 0) {
    if (count % 2 === 1) result += base;
    base += base;
    count = Math.floor(count / 2);
  }

  return result;
}

repeatFast("ab", 5); // "ababababab"

trim — remove leading and trailing whitespace

What it does: Returns a new string with whitespace removed from both ends. trimStart and trimEnd are one-sided versions.

"  hello  ".trim();      // "hello"
"  hello  ".trimStart(); // "hello  "
"  hello  ".trimEnd();   // "  hello"

Conceptual logic: Walk inward from the left until you hit a non-whitespace character. Walk inward from the right until you hit a non-whitespace character. Return the slice between those two positions.

Implementation:

String.prototype.myTrim = function() {
  const str = String(this);
  const whitespace = " \t\n\r\f\v";

  let left = 0;
  let right = str.length - 1;

  while (left <= right && whitespace.includes(str[left])) {
    left++;
  }

  while (right >= left && whitespace.includes(str[right])) {
    right--;
  }

  return str.slice(left, right + 1);
};

"  hello  ".myTrim(); // "hello"
"\t  hello\n".myTrim(); // "hello"

What counts as whitespace? The spec includes space, tab (\t), newline (\n), carriage return (\r), form feed (\f), and vertical tab (\v). A simple regex also works: /^\s+|\s+$/g.


padStart and padEnd — pad to a target length

What they do: If the string is shorter than the target length, add padding characters to the start or end until it reaches that length.

"5".padStart(3, "0"); // "005"
"hi".padEnd(5, ".");  // "hi..."
"hello".padStart(3);  // "hello" — already long enough

Conceptual logic: Calculate how much padding is needed. Repeat the pad string enough times to fill that space (it might need to be truncated if it does not divide evenly). Concatenate it with the original string.

Implementation:

String.prototype.myPadStart = function(targetLength, padString = " ") {
  const str = String(this);

  if (str.length >= targetLength) return str;
  if (padString.length === 0) return str;

  const padNeeded = targetLength - str.length;
  const repeated = padString.repeat(Math.ceil(padNeeded / padString.length));
  return repeated.slice(0, padNeeded) + str;
};

"5".myPadStart(3, "0"); // "005"
"5".myPadStart(5, "ab"); // "abab5"

The padEnd version is symmetric — concatenate the padding after the string instead of before.


Common Interview String Problems

These problems appear frequently in technical screens. Each one tests a specific kind of string reasoning.


Reverse a string

The simplest version uses built-in methods:

function reverse(str) {
  return str.split("").reverse().join("");
}

Interviewers often ask for the manual version:

function reverse(str) {
  let result = "";
  for (let i = str.length - 1; i >= 0; i--) {
    result += str[i];
  }
  return result;
}

reverse("hello"); // "olleh"

The follow-up is almost always: "What about Unicode characters?" Emoji and characters from some scripts are stored as surrogate pairs — two code units that represent one character. The split("") approach splits surrogate pairs, corrupting them. The correct approach uses the spread operator, which handles Unicode code points:

function reverseSafe(str) {
  return [...str].reverse().join("");
}

reverseSafe("hello 😊"); // "😊 olleh" — emoji intact

Check if a string is a palindrome

A palindrome reads the same forwards and backwards: "racecar", "level", "madam".

function isPalindrome(str) {
  const cleaned = str.toLowerCase().replace(/[^a-z0-9]/g, "");
  const reversed = [...cleaned].reverse().join("");
  return cleaned === reversed;
}

isPalindrome("racecar");      // true
isPalindrome("A man a plan a canal Panama"); // true
isPalindrome("hello");        // false

The two-pointer approach avoids creating the reversed string:

function isPalindrome(str) {
  const cleaned = str.toLowerCase().replace(/[^a-z0-9]/g, "");
  let left = 0;
  let right = cleaned.length - 1;

  while (left < right) {
    if (cleaned[left] !== cleaned[right]) return false;
    left++;
    right--;
  }

  return true;
}

Start from both ends and walk inward. If any pair of characters does not match, it is not a palindrome. This runs in O(n) time and O(1) extra space (besides the cleaned string).


Count character occurrences

Build a frequency map — a simple object where keys are characters and values are counts:

function charCount(str) {
  const counts = {};
  for (const char of str) {
    counts[char] = (counts[char] || 0) + 1;
  }
  return counts;
}

charCount("hello");
// { h: 1, e: 1, l: 2, o: 1 }

This pattern is the foundation for anagram checking, finding the most frequent character, and a dozen other string problems.


Check if two strings are anagrams

Two strings are anagrams if they contain exactly the same characters in the same quantities.

function areAnagrams(str1, str2) {
  if (str1.length !== str2.length) return false;

  const counts = {};

  for (const char of str1) {
    counts[char] = (counts[char] || 0) + 1;
  }

  for (const char of str2) {
    if (!counts[char]) return false;
    counts[char]--;
  }

  return true;
}

areAnagrams("listen", "silent"); // true
areAnagrams("hello", "world");   // false

Count characters in the first string. Then walk through the second string, decrementing each count. If a character appears in the second string but not the first (or appears too many times), return false.

A simpler but less efficient approach: sort both strings and compare them.

function areAnagrams(str1, str2) {
  const sort = s => s.split("").sort().join("");
  return sort(str1) === sort(str2);
}

Sorting is O(n log n). The frequency map approach is O(n).


Find the first non-repeating character

function firstUnique(str) {
  const counts = {};

  for (const char of str) {
    counts[char] = (counts[char] || 0) + 1;
  }

  for (let i = 0; i < str.length; i++) {
    if (counts[str[i]] === 1) return str[i];
  }

  return null;
}

firstUnique("aabbcde"); // "c"
firstUnique("aabb");    // null

Two passes: the first builds the frequency map, the second walks the string in order and returns the first character with a count of 1. Order matters here — you need to preserve the original sequence.


Truncate a string to a word boundary

A real-world utility: shorten a string to fit a character limit, but do not cut in the middle of a word.

function truncate(str, maxLength) {
  if (str.length <= maxLength) return str;

  const truncated = str.slice(0, maxLength);
  const lastSpace = truncated.lastIndexOf(" ");

  if (lastSpace === -1) return truncated;
  return truncated.slice(0, lastSpace) + "...";
}

truncate("The quick brown fox jumps over the lazy dog", 20);
// "The quick brown..."

lastIndexOf(" ") finds the last space within the truncated region. Slicing at that position avoids cutting through a word.


Implement String.prototype.split for a single character delimiter

This is less common as an interview question, but it illustrates the concept behind all parsing:

function mySplit(str, delimiter) {
  if (delimiter === "") return [...str];

  const result = [];
  let current = "";

  for (const char of str) {
    if (char === delimiter) {
      result.push(current);
      current = "";
    } else {
      current += char;
    }
  }

  result.push(current);
  return result;
}

mySplit("a,b,c,d", ","); // ["a", "b", "c", "d"]
mySplit("hello", "l");   // ["he", "", "o"]

Walk through the string one character at a time. When you hit the delimiter, push the accumulated characters as a new element and reset. When you finish, push whatever remains. Note that consecutive delimiters produce empty strings — that is the correct behaviour, matching the native split.


Understanding Built-in Behaviour

The deeper reason to implement these utilities yourself is not to be able to ship polyfills — it is to understand what the methods actually promise.

When you use " hello ".trim(), you might assume it handles all whitespace. But have you thought about vertical tab? Form feed? Non-breaking space (\u00A0)? The native trim handles all of these because the spec defines whitespace precisely. A naive implementation using just " " would miss them.

When you use "ab".repeat(Infinity), you get a RangeError. That edge case is in the spec. If your polyfill does not throw there, it behaves differently from the native method — and code that depends on that error will fail silently.

These are the kinds of details that separate a correct polyfill from a broken one, and they are also what interviewers probe. "What if the input is empty?" "What if count is 0?" "What if the delimiter appears at the start?" Every time you write a utility from scratch and think through the edge cases, you build a more accurate mental model of the built-in.

The goal is not to memorise implementations. It is to reach the point where you can reconstruct them from first principles, because you genuinely understand what they are supposed to do.


Wrapping Up

String methods are not black boxes. They are algorithms — each one translatable into a loop, a comparison, a character-by-character walk through the input. Writing polyfills forces you to make that translation explicit.

The interview questions in this article are not obscure puzzles. They are the same operations that string methods perform, just without the method available. Reversing a string is what split("").reverse().join("") does internally. Checking for a substring is what includes does. Building a frequency map is what powers most character-counting operations.

Understand the logic behind the built-ins, and both the interviews and the occasional need for a real polyfill become straightforward.

More from this blog