JAVASCRIPT / STRINGS AND TEMPLATE LITERALS
Slicing and extracting substrings
Extract any part of a string with slice, reason about end-exclusive and negative indices, and know why substring behaves differently.
What you will learn
- Use slice(start, end) knowing end is excluded, so the result is end - start long
- Grab or drop trailing characters with negative indices like slice(-4) and slice(0, -4)
- Predict slice vs substring results when the start index is larger than the end
- Capture the returned string, since slicing never changes the original value
Understanding Slicing and extracting substrings
The two numbers you hand to slice are not characters, they are boundary positions between characters: 0 sits before the first character and length sits after the last one. slice(start, end) copies everything lying between boundary start and boundary end, which is why the character at end is left out and why the result is always exactly end - start characters long. Two useful consequences fall straight out of that model: slice(4, 4) is an empty string because there is nothing between a boundary and itself, and s.slice(0, k) + s.slice(k) rebuilds s for any k, because the two ranges meet at the same boundary without overlapping.
A negative argument is resolved as length + n before any copying happens, so on a 13-character string slice(-4) is literally slice(9) and slice(0, -5) is slice(0, 8). That saves you from writing s.length - 4 twice and keeps the intent readable: negative numbers mean counting boundaries from the right edge. Anything still outside the range 0 to length after that adjustment is clamped, and a range whose start is not before its end yields an empty string, so slice never throws on bad indices. Omitting the second argument means the copy runs to the final boundary.
substring predates this design and handles the same two numbers differently: negatives are clamped to 0 rather than counted from the right, and if the first number is larger than the second it silently swaps them, so substring(8, 4) quietly behaves like substring(4, 8). That swap is convenient exactly once and a hidden bug the rest of the time, which is why slice is the default choice. The third method you will still see in old code, substr, treats its second argument as a length and exists only in Annex B for legacy web compatibility. All three return new strings because strings are immutable, so nothing you do here can modify the value you started from.
const label = "INV-2026-0042";
console.log(label.slice(4, 8)); // characters at 4, 5, 6, 7
console.log(label.slice(-4)); // start at 13 - 4 = 9
console.log(label.slice(0, -5)); // stop at boundary 13 - 5 = 8
console.log(label.substring(8, 4)); // substring swaps a reversed pair
console.log(`[${label.slice(8, 4)}]`); // slice does not swap: empty string
console.log(label); // the source is untouchedslice copies the characters between two boundary positions, excluding the end boundary and resolving negative arguments as length + n, and hands back a new string.
Worked examples
Truncating without overflow
Builds a preview string of a fixed maximum width and shows that an end index past the string is clamped instead of failing.
function preview(text, max) {
if (text.length <= max) return text;
return text.slice(0, max - 1) + "…";
}
console.log(preview("Immutable strings", 9));
console.log(preview("Short", 9));
console.log("abc".slice(0, 99));Example explained
Line 1slice(0, max - 1) takes eight characters so that adding the ellipsis lands on exactly nine.
Line 2The length guard is needed because slice alone would happily shorten nothing and still append the ellipsis.
Line 3slice(0, 99) on a three-character string clamps the end to 3 and returns "abc" rather than throwing or padding.
The split-and-rejoin invariant
Demonstrates that a single index splits a string into two non-overlapping halves because the end boundary is exclusive.
const word = "boundary";
const k = 3;
console.log(word.slice(0, k));
console.log(word.slice(k));
console.log(word.slice(0, k) + word.slice(k) === word);
console.log(word.slice(4, 4) === "");Example explained
Line 1slice(0, 3) stops before index 3, so the character 'n' belongs to the second piece, not the first.
Line 2Omitting the second argument makes slice(3) run to the end boundary at length 8.
Line 3The two pieces concatenate back to the original precisely because index 3 is used once as an end and once as a start.
Line 4slice(4, 4) spans zero characters, giving an empty string rather than the character at index 4.
Where substring differs
Contrasts negative-index handling in slice and substring, and shows that slicing counts UTF-16 units, not visible characters.
const s = "JavaScript";
const emoji = "😀";
console.log(s.slice(-6));
console.log(s.substring(-6));
console.log(s.substring(4, -1));
console.log(emoji.length);
console.log(emoji.slice(0, 1) === emoji);Example explained
Line 1slice(-6) resolves the start to 10 - 6 = 4, so the copy begins at the capital S.
Line 2substring clamps -6 up to 0 instead, returning the whole string and hiding the mistake.
Line 3substring(4, -1) clamps -1 to 0 and then swaps the pair, so it quietly evaluates as substring(0, 4).
Line 4The emoji occupies two UTF-16 units, so slice(0, 1) keeps only its first half and the comparison fails.
Important notes
Out-of-range and inverted ranges are clamped rather than reported, so an index bug shows up later as a suspiciously empty or over-long string.
slice counts UTF-16 code units, so a cut inside an emoji or other surrogate pair leaves half a character; iterate with Array.from(str) when that matters.
Common mistakes
Passing a count as the second argument: "hello".slice(1, 3) is "el", not "ell", so every extracted field comes out one character short.
Using substring for a suffix: "abcdef".substring(-2) returns the whole string instead of "ef", and because nothing throws the bug travels far from its cause.
Calling text.slice(0, 5) as if it edited text, then printing text and finding it unchanged, because the new string was never assigned.
Try it yourself
Change, predict, then run
In a browser console, take the string "2026-09-03T19:19" and use slice to print the year, the "09-03" part, and the time separately. Then check that slice(0, 10) + slice(10) is strictly equal to the original string.
Open the JavaScript workspaceCheck your understanding
Why does "hello world".slice(6, 3) return an empty string while "hello world".substring(6, 3) returns "lo "?
- substring reorders its two arguments so the smaller becomes the start, while slice keeps the order given and an inverted range spans no characters
- slice reads 3 as a length, so it copies zero characters starting from position 6
- substring counts its second argument from the end of the string, which resolves to index 3 from the right
- slice refuses any call where the arguments are not in ascending order and returns "" as an error signal
Show answer
substring normalizes its pair by sorting them, so (6, 3) is treated as (3, 6) and yields the characters at 3, 4 and 5. slice leaves the pair alone, and a start boundary that comes after the end boundary describes nothing, so the copy is empty. Reading the second argument as a length is the behaviour of the legacy substr, not of slice, and slice does not treat the empty result as an error.