JavaScript strings are immutable primitive values with a surprisingly large set of methods on String.prototype. Most of them earn their place; a few are traps. This lesson covers the ones you will actually reach for, and flags the two that quietly surprise people.
How do I get the length of a string and read a character?
Every string has a length, including the empty one, which has a length of zero. To read a single character, use at() — the only spelling that accepts a negative index.
const label = 'JavaScript';
console.log(label.length); // 10
console.log(label.at(0)); // J
console.log(label.at(-1)); // t
console.log(label[label.length - 1]); // t
console.log(label.charAt(1)); // a
charAt is the older spelling. It works, but it returns an empty string for an index that is out of range, which is quietly different from at() returning undefined. Prefer at(), partly because the negative index saves you from writing length - 1 every time. One thing to internalise about all of these methods: strings are immutable, so every one of them returns a new value and leaves the original untouched.
How do I take a part of a string?
slice() takes a start index and an end index and returns the text between them. The end index is exclusive, and negative indices count back from the end of the string.
const path = '/api/v1/users';
console.log(path.slice(1, 4)); // api
console.log(path.slice(-4)); // sers
console.log(path.slice(0, -5)); // /api/v1/
console.log(path.slice(2)); // pi/v1/users
The third line is the one people miss: a negative end index drops that many characters from the end rather than counting from the start. slice is also forgiving — an index beyond the end of the string gives you an empty string rather than an error, which makes it safe to run on user input. substring exists too, but it treats negative numbers as zero and swaps its arguments if they arrive in the wrong order, so slice is nearly always the clearer choice.
How do I check whether a string contains something?
For a yes-or-no question, includes is the one to remember. startsWith and endsWith are the specialised versions for the two ends.
const email = '[email protected]';
console.log(email.includes('@')); // true
console.log(email.endsWith('.com')); // true
console.log(email.startsWith('ada')); // true
console.log(email.indexOf('@')); // 3
Matching is case sensitive, so email.includes('EXAMPLE') is false. If you need case-insensitive searching, lower-case both sides — email.toLowerCase().includes('example') — or use a regular expression with the i flag. The lower-case version is usually clearer and avoids reaching for a pattern you do not need.
What do trim, repeat and padStart do?
Three small methods that turn up in formatting code far more often than you would expect.
const raw = ' spaced ';
console.log(JSON.stringify(raw.trim())); // "spaced"
console.log('ab'.repeat(3)); // ababab
console.log('42'.padStart(4, '0')); // 0042
console.log('7'.padStart(3, '-')); // --7
trim removes whitespace from both ends and hands back a new string, which is why assigning the result back is the step people forget — calling raw.trim() and then printing raw shows the original, unchanged. repeat duplicates a string a set number of times. padStart and padEnd add padding until the string reaches a target length, which is how you zero-pad an order number or line up a column.
Why is string length sometimes wrong?
The one place length surprises you is text containing characters outside the basic multilingual plane, such as emoji. Each of those is made of two code units, so length counts them twice.
const wave = '\u{1F44B}';
console.log(wave.length); // 2
console.log([...wave].length); // 1
Spreading the string with ... splits it into code points rather than code units, which is why the second number is the one a human would call the length. The same trick fixes slicing the last character of a string that ends in an emoji: slice(-2) cuts a surrogate pair in half and produces a broken character, while Array.from(text).at(-1) returns the whole one. For plain ASCII text the two agree, which is exactly why this bug only ever shows up in production.
There is a related trap with slice on text that contains combining characters — a letter built from several code points, such as some accented letters written in decomposed form. There the character count is right but the character boundaries are not, and the fix is the same: normalise the text first, then work with code points rather than code units.
Which string methods should I memorise?
| Method | Returns | Note |
|---|---|---|
str.length | Number of UTF-16 code units | Emoji count as two |
str.at(i) | Character at an index | Accepts negatives; undefined out of range |
str.slice(a, b) | Text between two indexes | End is exclusive; negatives count from the end |
str.includes(x) | A boolean | Case sensitive |
str.startsWith(x) / endsWith(x) | A boolean | Case sensitive |
str.trim() | String without outer whitespace | Returns a new string |
str.repeat(n) | The string n times | n must be a whole number |
str.padStart(len, pad) | A padded string | Also padEnd |
str.toUpperCase() / toLowerCase() | A case-converted copy | Use the locale versions for locale rules |
Try it yourself: write a file called labels.js and store ' order-0042 ' in a variable. Print the length before trimming and the length after, to see the difference. Use padStart to turn '42' into '0042' and print it. Use startsWith to check whether the trimmed value starts with 'order-', and includes to check whether it contains a dash. Then use slice to pull out just the numeric part and Number to turn it into the number 42. Finish by printing the last character with at(-1) and printing the character count again with [...text].length to compare the two numbers.