JAVASCRIPT / STRINGS AND TEMPLATE LITERALS
Searching within strings with includes and indexOf
Test whether a substring is present with includes and locate it with indexOf, handling the -1 sentinel, case sensitivity and repeated matches correctly.
What you will learn
- Choose includes for a yes/no test and indexOf when you need the match position
- Compare indexOf against -1 explicitly instead of relying on truthiness
- Resume a scan with the second fromIndex argument to reach every occurrence
- Lowercase both sides before searching, since both methods compare case exactly
Understanding Searching within strings with includes and indexOf
Looking inside a string raises two different questions, and JavaScript splits them across two methods. includes reports whether a substring is present and returns true or false; indexOf reports where it starts and returns the offset of the first match, scanning left to right. Both compare content exactly, code unit by code unit, so Error and error are different needles, and neither one understands patterns or locale rules: they look for one literal run of characters and nothing else.
indexOf needs some way to say nothing was found, and it cannot use 0 for that, because 0 is the perfectly ordinary answer for a match at the very beginning of the string. So it returns an offset that can never be real, -1. That choice is behind the most common bug on this topic: writing if (s.indexOf(needle)) treats a match at position 0 as a failure, since 0 is falsy, while a genuine miss of -1 is truthy and sails straight through. The comparison has to be explicit, s.indexOf(needle) !== -1, and includes exists largely so you can skip writing it.
Both methods take an optional second argument, the position where the scan begins, and that is what makes them useful for more than one match. Calling indexOf again with the previous result plus one resumes the scan just past what you already found, and the loop ends naturally when the return value is -1; lastIndexOf performs the same walk from the right. The number indexOf hands back is an offset into the same numbering that slice, charAt and length use, which is why locating a delimiter and then cutting around it composes so directly.
const line = 'GET /api/users?page=2 HTTP/1.1';
console.log(line.includes('/api/')); // is it there at all?
console.log(line.includes('/API/')); // case is compared exactly
console.log(line.indexOf('?')); // where is it?
console.log(line.indexOf('#')); // -1 means nowhere
// indexOf pays off when the position itself is what you need
const start = line.indexOf('?') + 1;
const end = line.indexOf(' ', start);
console.log(line.slice(start, end));
// 0 is a real position, so truthiness is the wrong test
console.log(line.indexOf('GET'), Boolean(line.indexOf('GET')));indexOf answers where with a number and reserves the impossible value -1 for a miss because 0 already means a match at the start, while includes is that same scan with the answer reduced to a boolean.
Worked examples
Finding every occurrence
Uses the second argument of indexOf to walk through all matches instead of only the first.
const text = 'to be or not to be';
const found = [];
let at = text.indexOf('be');
while (at !== -1) {
found.push(at);
at = text.indexOf('be', at + 1);
}
console.log(found.join(', '));
console.log(text.lastIndexOf('be'), text.indexOf('be'));Example explained
Line 1The first indexOf call with no second argument starts at 0 and reports offset 3.
Line 2Passing at + 1 moves the next scan past the match just recorded; without it indexOf would keep returning 3 and the loop would never end.
Line 3The loop stops on -1, which is why a value outside the valid index range makes such a convenient sentinel.
Line 4lastIndexOf scans right to left, so it reports 16 while plain indexOf reports 3 for the same needle.
Case sensitivity and regular expressions
Shows why a lowercase needle misses a capitalised match, and how the two methods react differently to a regex argument.
const title = 'Introducing the Fetch API';
console.log(title.includes('fetch'));
console.log(title.toLowerCase().includes('fetch'));
console.log(title.indexOf(/Fetch/));
try {
title.includes(/Fetch/);
} catch (err) {
console.log(err.constructor.name);
}Example explained
Line 1includes compares code units exactly, so lowercase fetch does not match the capital Fetch in the title.
Line 2Lowercasing the haystack is the usual way to get a case-insensitive test, because neither method accepts flags.
Line 3indexOf coerces its argument to a string, so the regex becomes the seven characters /Fetch/ and is simply not found: a silent -1 rather than an error.
Line 4includes deliberately rejects a regular expression with a TypeError, so the same mistake fails loudly there.
The empty needle matches everything
Demonstrates why an unguarded empty search term makes every string look like a hit.
const tags = ['react', 'preact', 'svelte'];
function search(query) {
return tags.filter(tag => tag.includes(query)).join(', ');
}
console.log(search('react'));
console.log(search(''));
console.log('react'.indexOf('', 99));Example explained
Line 1preact matches react because includes looks anywhere in the string, not only at the start.
Line 2An empty query is present in every string, so a search box that filters with includes shows the whole list before the user types anything.
Line 3With an empty needle indexOf returns the starting position clamped to the length, so 99 becomes 5 instead of -1.
Important notes
Positions count UTF-16 code units, not characters you can see. An astral character such as an emoji or a rare ideograph takes two units, so every offset after it shifts by two, which is consistent with how slice, charAt and length count.
Neither method does pattern matching or locale-aware comparison; when you need a pattern or a case-insensitive flag, use a regular expression with search, match or test instead.
Common mistakes
Writing if (url.indexOf('https')) as a presence check: a match at the start returns 0, which is falsy, so the branch is skipped in exactly the case it was meant to catch.
Using indexOf(needle) > 0 instead of !== -1, which quietly rejects any match that begins at position 0.
Passing a regular expression: includes throws a TypeError, and indexOf searches for the literal slash-delimited text and returns -1, so the bug looks like missing content rather than a wrong argument type.
Try it yourself
Change, predict, then run
In a browser console, set const q = 'name=ada&role=admin&id=7' and print the value of role by locating 'role=' with indexOf, skipping past its length, then finding the next '&' from that offset. Make it print missing when the key is absent, and make sure it still works when the wanted pair is last and no '&' follows.
Open the JavaScript workspaceCheck your understanding
Given const url = 'https://example.com', why does if (url.indexOf('https')) never run its body?
- indexOf never matches at the very start of a string unless you pass a fromIndex
- indexOf returns -1 for the match, and -1 is falsy
- indexOf returns 0 for a match at the start, and 0 is falsy
- The condition throws, because indexOf returns a number where if requires a boolean
Show answer
The match begins at the first character, so indexOf reports 0, and 0 is one of the values JavaScript treats as false, so the guard behaves as if the substring were missing. Option 2 is tempting because -1 really is the miss value, but -1 is truthy, so a genuine miss would pass the test: the behaviour is exactly inverted, which is why the check must be written as indexOf(...) !== -1 or replaced with includes.