Strings and text processing · Lesson 38 of 77 · practice
Finding things and cutting pieces out
About 55 minutes.
By the end of this lesson you can
- Ask whether text contains, starts with or ends with something and get a true or false answer.
- Find WHERE something is with indexOf and lastIndexOf, and handle the -1 'not found' answer deliberately.
- Cut a piece out of a string with slice, using positions counted from the start and from the end.
- Explain how substring differs from slice and pick one on purpose.
- Pull a value out from between two markers without breaking when a marker is missing.
Two different questions: is it there, and where is it
Explanation
The first question is a yes or no. `text.includes("@")` answers it: true or false, nothing else. Its two relatives ask about the ends — `text.startsWith("https://")` and `text.endsWith(".png")`. Use these whenever a true/false answer is all you need, because they say what you mean in the code.
The second question wants a position. `text.indexOf("@")` gives you the position of the FIRST @ — a number counting from 0, the same numbering as square brackets. `text.lastIndexOf(".")` gives you the position of the LAST one, which is what you want for file extensions, since a filename may contain several dots.
Both of the position methods have one answer reserved for failure: -1, meaning it is not in there at all. That value is not a position — nothing lives at -1 — it is a signal, and the whole of the third section below is about not walking straight past it.
indexOf also accepts a second argument: a position to start looking from. `text.indexOf("]", 5)` finds the first ] at or after position 5. That is how you find a closing marker that must come after an opening one.
slice: from here, up to but not including there
Explanation
`text.slice(start, end)` gives you a new string containing the characters from `start` up to but NOT including `end`. `"abcdef".slice(2, 4)` is "cd": positions 2 and 3, stopping just before 4.
Leave the second argument out and slice runs to the end of the string: `"abcdef".slice(2)` is "cdef". This is the form you will use most.
Negative numbers count back from the end, exactly like `.at()`. `"abcdef".slice(-2)` is the last two characters, "ef", without you having to work out any lengths. `slice(1, -1)` drops the first and last characters, which is a neat way to strip a pair of surrounding quotes.
And slice never complains. Ask for positions beyond the end of the string and you get whatever exists, possibly the empty string "". No error, no exception — which is convenient right up until it hides a bug from you.
Positions sit BETWEEN the characters
An analogy
The 'up to but not including' rule stops feeling arbitrary if you stop imagining the numbers as labels ON the characters and start imagining them as gaps BETWEEN them, like fence posts with panels in between.
In "abcdef", position 0 is the gap before the a, position 1 is the gap between a and b, and position 6 is the gap after the f. `slice(2, 4)` then reads as: cut at gap 2, cut at gap 4, and hand me the piece between the cuts — which is exactly "cd", two characters.
This also explains why `end - start` is always the length of the piece you get back, and why `slice(4, 4)` — two cuts in the same place — gives you an empty string rather than one character. Once you see the fence posts, the off-by-one errors mostly stop.
-1 is not 'nowhere', and JavaScript will not remind you
A common mistake
Here is the bug, and it is extremely common. You want everything after the colon, so you write `text.slice(text.indexOf(":") + 1)`. When there is a colon, it works perfectly. When there is not, indexOf returns -1, the + 1 makes it 0, and slice(0) hands back THE ENTIRE STRING.
Nothing throws. Nothing warns. Your function returns a plausible-looking value that happens to be completely wrong, and the mistake surfaces three screens away as a puzzling piece of text in a database.
So make the check explicit. Get the position into a variable, compare it against -1 first, decide what 'not found' should mean for your function, and only then slice. Two extra lines buy you a function that tells the truth for every input, not merely for the inputs you tried.
The same trap is why `if (text.indexOf("x"))` is wrong as a presence test in two directions at once: -1 is truthy, so 'not found' passes the test, and 0 is falsy, so a match at the very start fails it. Use `includes()` when you want a yes or no.
slice, substring and the one to avoid
Explanation
`substring` looks like a synonym for `slice` and mostly behaves like one, but it differs in two ways that matter. If you pass the arguments the wrong way round, substring SWAPS them for you: `"abcdef".substring(4, 2)` returns "cd", while `slice(4, 2)` returns "". And substring treats any negative number as 0, so `substring(-2)` returns the whole string where `slice(-2)` returns the last two characters.
Neither behaviour is wrong, but substring's silent argument-swapping turns a genuine bug — your two positions in the wrong order — into a confidently incorrect answer. slice failing loudly with an empty string is easier to debug.
Prefer slice. Learn substring only well enough to read other people's code. There is a third one, `substr`, whose second argument is a LENGTH rather than a position; it is a deprecated legacy feature kept for compatibility, and you should not write new code with it.
What to take away
Recap
includes / startsWith / endsWith answer yes-or-no. indexOf / lastIndexOf answer where, and answer -1 when the thing is absent — check for that before you use the number.
slice(start, end) takes from start up to but not including end, accepts negatives, and never throws. Positions are the gaps between characters, which is why end - start is the length you get.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
Taking a file path apart
One realistic string and six different questions asked of it. Read each line as a question in English first — does it contain 2026? where is the first slash? — and only then as code.
1const path = "reports/2026/summary.final.txt";2console.log(path.includes("2026"));3console.log(path.startsWith("reports/"));4console.log(path.indexOf("/"));5console.log(path.lastIndexOf("."));6console.log(path.slice(path.lastIndexOf(".") + 1));7console.log(path.slice(0, 7));- line 2
A yes-or-no question, so the answer is a boolean.
- line 4
The FIRST slash is at position 7, counting from 0. There is a second slash later that this call ignores.
- line 5
lastIndexOf, not indexOf: this filename has two dots, and the extension is after the last one.
- line 6
Everything after the last dot. The + 1 steps over the dot itself so it is not part of the answer.
- line 7
The first seven characters — positions 0 to 6, stopping just before 7.
What it prints
truetrue726txtreports
The -1 bug, shown rather than described
The same slice expression run against text that does not contain the marker at all. The second line is the bug: it returns something entirely plausible and entirely wrong. The last two lines are the fix.
1const text = "no colon here";2console.log(text.slice(text.indexOf(":") + 1));3console.log(text.indexOf(":"));4const at = text.indexOf(":");5console.log(at === -1 ? "no value found" : text.slice(at + 1));- line 2
The whole string comes back. indexOf gave -1, the + 1 made it 0, and slice(0) means 'everything'.
- line 3
There it is: -1 means the colon is not in the string at all.
- line 5
The fix: name the position, test it against -1, and decide what absence should mean before slicing.
What it prints
no colon here-1no value found
Where slice and substring disagree
Four calls, two methods, two disagreements. This is worth running once so that when you read someone else's substring call you know it may be quietly reordering its arguments.
1console.log("abcdef".slice(2, 4));2console.log("abcdef".slice(4, 2));3console.log("abcdef".substring(4, 2));4console.log("abcdef".slice(-2));5console.log("abcdef".substring(-2));- line 1
Both methods agree when the arguments are in order: cd.
- line 2
slice with start after end gives the empty string. Nothing printed between the quotes here — the line is blank.
- line 3
substring silently swapped the arguments and answered as if you had written (2, 4).
- line 4
slice counts -2 back from the end: the last two characters.
- line 5
substring treats the negative as 0, so you get the whole string.
What it prints
cdcdefabcdef
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
The word has ten characters. Work out each answer with the fence-post picture — cut here, cut there, hand me the piece between — before you run it.
const word = "javascript";console.log(word.slice(0, 4));console.log(word.slice(4));console.log(word.slice(-6));console.log(word.slice(4, 4).length);
You wrote `const at = email.indexOf("@")` and `at` is -1. What does that tell you?
What does `"abcdef".substring(4, 2)` return?
Write it yourself
Exercise · core · 7 tests
Get a file's extension
Write a function `fileExtension(name)` that returns the extension of a filename, in lower case and WITHOUT the dot. `"notes.md"` gives `"md"` and `"photo.JPEG"` gives `"jpeg"`. Three cases must give back the empty string `""` instead: - there is no dot at all, as in `"README"`; - the only dot is the very first character, as in `".gitignore"` — that is a hidden file, not an extension; - the dot is the last character, as in `"archive."` — there is nothing after it. A filename may contain several dots (`"summary.final.TXT"` is a `txt` file), so think carefully about which dot you want to find.
Returns the text after the LAST dot, lower-cased, with no dot included. Returns "" when there is no dot, when the only dot is at position 0, or when the dot is the final character. Always returns a string, never undefined.
Define fileExtension at the top level of your code — the tests call it by name.
Exercise · stretch · 8 tests
Pull out the value between two markers
Write a function `between(text, open, close)` that returns whatever sits between the first `open` marker and the first `close` marker that comes AFTER it. `between("hello [world] bye", "[", "]")` gives `"world"`. The markers are not included in the result, and they may be more than one character long: `between("a{{b}}c", "{{", "}}")` gives `"b"`. Return the empty string `""` whenever there is nothing to extract: no opening marker, no closing marker after the opening one, or nothing between them. Two things to be careful about. The gap between the markers may be empty, and the closing marker must be searched for from AFTER the opening one — otherwise `"] first ["` looks like a match when it is not.
Returns the text strictly between the first occurrence of open and the first occurrence of close at or after the end of that opening marker. Markers of any length are supported, including open and close being the same string. Returns "" when either marker is missing, when close only appears before open, or when the markers are adjacent.
Define between at the top level of your code — the tests call it by name.
Worth remembering
- What does indexOf return when the text is not found, and why is that dangerous?
-1. It is a signal, not a position, so arithmetic on it lies: indexOf(x) + 1 becomes 0 and slice(0) returns the WHOLE string. Compare against -1 before using the number.
- What does slice(2, 4) return, and why that many characters?
The characters at positions 2 and 3 — up to but not including 4. Think of positions as gaps between characters: cut at gap 2, cut at gap 4, take the piece between. So end - start is always the length.
- One reason to prefer slice over substring?
substring silently swaps its arguments when start > end and treats negatives as 0, so a genuine bug returns a confident wrong answer. slice returns "" instead, which is easier to notice, and it supports negative positions.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
MDN — String.prototype.slice()
Check there: slice(indexStart, indexEnd) extracts up to but not including indexEnd, counts negative arguments back from the end, and returns an empty string when indexStart is not before indexEnd.
MDN — String.prototype.indexOf()
Check there: indexOf returns -1 when the search string is absent, and accepts an optional position to start searching from.
MDN — String.prototype.substring()
Check there: substring swaps its arguments when start is greater than end, and clamps negative or NaN arguments to 0.
MDN — String.prototype.includes()
Check there: includes returns a boolean rather than a position.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown