Skip to content
Let’s talk

18 min read

A Practical Guide to JavaScript String Methods (With Real-Life Examples)

Hassib ModdasserSoftware engineer

Someone can’t log in to your app. They’re sure their email is right, and it almost is: they pasted it from a note, and a space came along at the end. To your code, 'ella@example.com ' and 'ella@example.com' are two different strings, so as far as it knows, that account doesn’t exist.

The fix is one method call, trim(), and most string bugs are like that. Text is everywhere in an interface: form fields, search boxes, URLs, names, labels. Nearly everything you do with it is one of three jobs: cleaning it up, searching through it, or reshaping it for display.

In this article, I’ll go through the string methods I use most, grouped by the job they do, with real examples from interface work: cleaning form input, building a search, making URL slugs, masking card numbers, shortening long text, and dealing with emoji. At the end, we’ll put several of them together in a searchable list in React.

A few things to know first

Strings in JavaScript can’t be changed. Every string method returns a new string and leaves the original alone. That sounds obvious until you write this:

let email = '  ella@example.com '
email.trim()
 
console.log(email) // '  ella@example.com '

trim() did its job. It returned a clean copy, and the code threw it away. You have to use the result:

email = email.trim() // 'ella@example.com'

Two more basics. Positions in a string start at 0, so in 'JavaScript', the J is at index 0 and the last t is at index 9, which is always length - 1. And almost every method is case-sensitive: 'JavaScript'.includes('script') is false. Keep that in mind, because it’s behind a lot of “why doesn’t my search work?” moments.

Cleaning up input: trim, trimStart, and trimEnd

People type all sorts of things into forms: spaces before and after, double spaces in the middle, random capital letters. Cleaning that up before you use it saves you from a whole category of bugs.

trim() removes whitespace from both ends of a string, and trimStart() and trimEnd() do the same for just one end. Whitespace here means spaces, tabs, line breaks, and a few invisible space characters:

const input = '   Hassib Moddasser  '
 
input.trim() // 'Hassib Moddasser'
input.trimStart() // 'Hassib Moddasser  '
input.trimEnd() // '   Hassib Moddasser'

For names and similar fields, it’s worth collapsing repeated spaces in the middle too, so a name with four spaces in it doesn’t end up in your database:

const name = '  Hassib    Moddasser '.trim().replace(/\s+/g, ' ')
// 'Hassib Moddasser'

\s+ means “one or more whitespace characters in a row”, and the g flag replaces every match instead of only the first. More on replace() in a bit.

The contact form on this site does both. Every field is trimmed on the server before it’s checked, and extra spaces in the name are collapsed before it goes into the email subject line.

Watch out

Don’t trim on every keystroke. If a React input calls setName(event.target.value.trim()), each space disappears the moment it’s typed, so nobody can type “Hassib Moddasser” at all. Keep exactly what the user typed in state, and trim when you use the value: on submit, before searching, or before saving.

Changing case

const greeting = 'Hello, World!'
 
greeting.toLowerCase() // 'hello, world!'
greeting.toUpperCase() // 'HELLO, WORLD!'

The most common reason to change case isn’t display, it’s comparison. Lowercase both sides, and the check stops caring how someone typed:

function isSameEmail(a, b) {
  return a.trim().toLowerCase() === b.trim().toLowerCase()
}
 
isSameEmail('Ella@Example.com ', 'ella@example.com') // true

There’s no built-in method for capitalizing only the first letter, but two methods together do it:

const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1)
 
capitalize('hello world') // 'Hello world'

If the change is purely visual, like an uppercase label on a button, use CSS instead: text-transform: uppercase, or uppercase in Tailwind. Your HTML keeps the text as written, and the browser handles how it looks.

Case rules also depend on the language. In Turkish, the capital I and the lowercase i aren’t a pair: i goes with a dotted capital İ, and I goes with a dotless ı. The regular methods follow general Unicode rules, so for languages like Turkish, use the locale-aware versions:

'istanbul'.toUpperCase() // 'ISTANBUL'
'istanbul'.toLocaleUpperCase('tr') // 'İSTANBUL'
'İstanbul'.toLocaleLowerCase('tr') // 'istanbul'

Without the 'tr', lowercasing 'İstanbul' gives you an i followed by a separate combining dot, so the result is one character longer than it looks. That’s the kind of thing that breaks a search for one city name and nothing else.

Getting a single character: charAt, at, and brackets

There are three ways to read one character from a string:

const word = 'JavaScript'
 
word.charAt(0) // 'J'
word[0] // 'J'
word.at(-1) // 't'

at() is the newest of the three, and its best feature is negative positions: at(-1) is the last character, at(-2) the one before it. That’s much nicer than word[word.length - 1].

The difference between charAt() and the other two shows up when the position doesn’t exist. charAt() returns an empty string, while brackets and at() return undefined. That sounds like trivia, but it matters in a function like this one, which builds the initials for an avatar:

function getInitials(fullName) {
  const words = fullName.trim().split(/\s+/)
  const last = words.length > 1 ? words.at(-1) : ''
  return (words[0].charAt(0) + last.charAt(0)).toUpperCase()
}
 
getInitials('Hassib Moddasser') // 'HM'
getInitials('Mary Jane van der Berg') // 'MB'
getInitials('Ella') // 'E'

With last[0] instead of last.charAt(0), 'Ella' would give you 'EUNDEFINED'. JavaScript turns undefined into text when you add it to a string, and then toUpperCase() makes it shout. With charAt(), a missing letter is just an empty string.

split(/\s+/) splits on any run of whitespace, so a double space between names doesn’t create an empty “word”. We’ll look at split() properly in a moment.

Most searching answers one of two questions: is it in there, and where is it?

For yes-or-no questions, use includes(), startsWith(), and endsWith(). They all return true or false:

const fileName = 'invoice-2026.pdf'
 
fileName.includes('2026') // true
fileName.startsWith('invoice') // true
fileName.endsWith('.pdf') // true

A search box is the classic use for includes(). Lowercase both sides so capital letters don’t matter:

const products = ['Sourdough loaf', 'Baguette', 'Rye bread', 'Croissant']
const query = 'BREAD'
 
products.filter((name) => name.toLowerCase().includes(query.toLowerCase()))
// ['Rye bread']

startsWith() is great for navigation. To highlight “Blog” in the menu on every blog post, you check whether the current path starts with /blog. Careful, though: /blog-archive starts with /blog too. Check for the slash that comes after it:

function isActive(pathname, href) {
  return pathname === href || pathname.startsWith(`${href}/`)
}
 
isActive('/blog', '/blog') // true
isActive('/blog/javascript-string-methods', '/blog') // true
isActive('/blog-archive', '/blog') // false

endsWith() is handy for checking file types in an upload form:

const isPdf = file.name.toLowerCase().endsWith('.pdf')

Watch out

A file name is only a hint. Anyone can rename setup.exe to cv.pdf. Checks like this help people pick the right file, but the real validation has to happen on the server.

When you need to know where something is, use indexOf(). It returns the position of the first match, or -1 if there isn’t one. An optional second argument tells it where to start looking:

const sentence = 'Keep betting on JavaScript!'
 
sentence.indexOf('betting') // 5
sentence.indexOf('Python') // -1
sentence.indexOf('betting', 7) // -1

lastIndexOf() does the same, but searches from the end. It’s the right tool for file extensions, because a file name can have more than one dot:

function getExtension(fileName) {
  const dot = fileName.lastIndexOf('.')
  return dot > 0 ? fileName.slice(dot + 1).toLowerCase() : ''
}
 
getExtension('holiday.photo.JPG') // 'jpg'
getExtension('README') // ''
getExtension('.env') // ''

The dot > 0 check covers names without a dot, where lastIndexOf() returns -1, and hidden files like .env, where the only dot is the first character.

Finally, search() works like indexOf() but takes a regular expression, so you can look for a pattern instead of exact text. For example, the position of the first digit:

'Order #4521 has shipped'.search(/\d/) // 7

Taking strings apart: slice and split

slice() returns part of a string, from a start position up to, but not including, an end position. Negative numbers count from the end:

const text = 'The quick brown fox jumps over the lazy dog.'
 
text.slice(4, 19) // 'quick brown fox'
text.slice(-4) // 'dog.'
text.slice(-9, -5) // 'lazy'

You’ll also see substring() and substr() in older code. substr() is deprecated, and substring() has a couple of odd rules, like quietly swapping its arguments when the first one is bigger. slice() covers everything they do, so it’s the only one of the three I use.

A real-life job for slice() is shortening long text, like a product description on a card or a notification preview. Cutting at an exact length usually splits a word in half, so it’s better to cut at the last space:

function truncate(text, maxLength) {
  if (text.length <= maxLength) return text
  const cut = text.slice(0, maxLength - 1)
  const lastSpace = cut.lastIndexOf(' ')
  return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd() + '…'
}
 
truncate('A practical guide to the JavaScript string methods you will use', 40)
// 'A practical guide to the JavaScript…'

The maxLength - 1 leaves room for the …, so the result is never longer than the limit.

Good to know

If text only needs to look shorter, CSS can do it without touching the string: text-overflow: ellipsis for a single line, or line-clamp for several (truncate and line-clamp-2 in Tailwind). Use JavaScript when the shorter text itself matters, like in a meta description, an SMS, or a push notification.

split() turns a string into an array, cutting it wherever it finds the separator you pass. join(), an array method, glues the pieces back together. Say a form lets people type tags separated by commas. Real input looks like the first line below, with uneven spaces and empty bits, and split() plus two array methods cleans it up:

const input = 'react, nextjs , , tailwind,'
const tags = input.split(',').map((tag) => tag.trim()).filter(Boolean)
 
console.log(tags) // ['react', 'nextjs', 'tailwind']
console.log(tags.join(', ')) // 'react, nextjs, tailwind'

filter(Boolean) drops the empty strings, because an empty string counts as false.

One trap: splitting on a single space breaks on double spaces. 'Hassib Moddasser'.split(' ') gives you ['Hassib', '', 'Moddasser'], with an empty string in the middle. Splitting on /\s+/, like in the initials example, avoids it.

Replacing text: replace and replaceAll

replace() swaps part of a string for something else. The catch is that with a plain string, it only replaces the first match:

const sentence = 'Mr Blue has a blue house and a blue car.'
 
sentence.replace('blue', 'red')
// 'Mr Blue has a red house and a blue car.'
 
sentence.replaceAll('blue', 'red')
// 'Mr Blue has a red house and a red car.'
 
sentence.replace(/blue/gi, 'red')
// 'Mr red has a red house and a red car.'

replaceAll() replaces every match. With a regular expression, the g flag does the same, and the i flag ignores case, which is why “Blue” changed too.

Removing characters is just replacing them with nothing. The WhatsApp links on this site work this way: WhatsApp wants the phone number as digits only, so every character that isn’t a digit gets removed:

const phone = '+1 (202) 555-0123'
const link = `https://wa.me/${phone.replace(/\D/g, '')}`
// 'https://wa.me/12025550123'

\D means “anything that isn’t a digit”, so the spaces, brackets, dash, and plus sign all go.

You can also pass a function instead of a replacement string. It runs for every match, and whatever it returns goes in. That’s enough for a tiny template system, which is handy for things like notification messages:

const template = 'Hi {name}, your order #{order} has shipped.'
const values = { name: 'Ella', order: 4521 }
 
template.replace(/\{(\w+)\}/g, (match, key) => values[key] ?? match)
// 'Hi Ella, your order #4521 has shipped.'

The (\w+) part captures the word inside the braces, so key is 'name' the first time and 'order' the second. If a key doesn’t exist, ?? match leaves the placeholder alone instead of printing undefined.

Watch out

replaceAll() with a regular expression needs the g flag. Without it, you get a TypeError instead of a quiet, half-done replacement, which is honestly the better way to fail.

Padding and repeating: padStart, padEnd, and repeat

padStart() and padEnd() add characters to the start or end of a string until it reaches a given length. They look minor, but they show up all over interfaces:

String(7).padStart(2, '0') // '07'
'Subtotal'.padEnd(14, '.') // 'Subtotal......'
 
const invoiceNumber = `INV-${String(42).padStart(5, '0')}` // 'INV-00042'

Numbers don’t have these methods, so convert them with String() first. The leading zero is also how a video player shows 3:07 instead of 3:7, which I cover in my guide to JavaScript Math functions.

Padding is how you mask sensitive details on screen, too. Keep the last four digits of a card number, or the first letter of an email, and fill in the rest:

function maskCardNumber(number) {
  const digits = number.replaceAll(' ', '')
  return digits.slice(-4).padStart(digits.length, '•')
}
 
function maskEmail(email) {
  const at = email.lastIndexOf('@')
  const name = email.slice(0, at)
  return name.charAt(0).padEnd(name.length, '•') + email.slice(at)
}
 
maskCardNumber('4242 4242 4242 4242') // '••••••••••••4242'
maskEmail('hassib@example.com') // 'h•••••@example.com'

That second one is perfect for messages like “We sent a code to h•••••@example.com”. Keep in mind that masking only hides characters on screen. If the full card number shouldn’t reach the browser at all, mask it on the server.

repeat() repeats a string a number of times. My favorite use is a quick star rating:

<span role="img" aria-label={`${rating} out of 5 stars`}>
  {'★'.repeat(rating) + '☆'.repeat(5 - rating)}
</span>

With a rating of 4, that shows ★★★★☆. The role and aria-label make screen readers announce “4 out of 5 stars” instead of reading out each symbol.

Pulling out patterns: match and matchAll

When you need to pull pieces out of a string, match() takes a regular expression. With the g flag, it returns every match in an array:

const post = 'Shipping the new site with #nextjs and #tailwind today'
 
post.match(/#\w+/g) // ['#nextjs', '#tailwind']

Here’s the trap: when nothing matches, match() returns null, not an empty array. So post.match(/#\w+/g).length works in every test with hashtags, then crashes on the first post without any. Add a fallback:

const hashtags = post.match(/#\w+/g) ?? []

matchAll() gives you more detail about each match, like capture groups and positions. It needs the g flag (without it, it throws a TypeError), and it returns an iterator, so loop over it with for...of or spread it into an array:

const comment = 'Thanks @ella and @mason for the review!'
 
for (const match of comment.matchAll(/@(\w+)/g)) {
  console.log(match[1], match.index)
}
// ella 7
// mason 17

That’s exactly what you need to turn mentions into profile links: the username for the URL, and the position to know where to cut the text.

Comparing text: localeCompare and normalize

localeCompare() compares two strings the way a person would, using the rules of a real language. It returns a negative number if the first string comes first, a positive number if it comes after, and 0 if they’re the same. That makes it perfect for sorting:

const names = ['Émile', 'zoe', 'Adam']
 
names.toSorted() // ['Adam', 'zoe', 'Émile']
names.toSorted((a, b) => a.localeCompare(b)) // ['Adam', 'Émile', 'zoe']

I wrote a whole article on sorting arrays in JavaScript, so I won’t repeat it here. But localeCompare() has another use that’s easy to miss: checking whether two strings are equal while ignoring case and accents. Set sensitivity to 'base':

'Résumé'.localeCompare('resume', undefined, { sensitivity: 'base' }) // 0

A 0 means they match. It’s a nice way to stop someone from adding a tag that already exists with different capitals or accents.

normalize() solves a stranger problem. The same text can be stored in more than one way. The “é” in “café” can be a single character, or a plain “e” followed by a separate accent mark. They look identical on screen, but JavaScript sees two different strings:

const a = 'caf\u00e9' // é as one character
const b = 'cafe\u0301' // e plus a combining accent
 
console.log(a === b) // false
console.log(a.length, b.length) // 4 5
console.log(a.normalize() === b.normalize()) // true

You won’t type text like this yourself, but it can arrive through copy and paste or from other systems. normalize() converts both to the same form, so they compare as equal.

It also powers a handy trick for search. With the 'NFD' form, every accented letter is split into the letter plus its accent, and then a regular expression removes the accents. Now someone who types “creme” still finds “Crème brûlée”:

function toSearchKey(text) {
  return text
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .toLowerCase()
}
 
toSearchKey('Crème brûlée').includes(toSearchKey('creme')) // true

The range from \u0300 to \u036f is where Unicode keeps those combining accent marks.

Put a few of these together and you get a slug function, which turns a title into the URL-friendly text you see in addresses like this post’s:

function slugify(title) {
  return title
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
}
 
slugify('  Crème Brûlée: 5 Easy Steps!  ') // 'creme-brulee-5-easy-steps'

Each line does one job: split off the accents, remove them, lowercase everything, turn every run of other characters into a single dash, and trim the dashes off both ends.

Watch out

[^a-z0-9] throws away every letter outside the English alphabet, so a title in Persian or Japanese becomes an empty string. If your titles can be in other scripts, use Unicode property escapes instead: .replace(/[^\p{L}\p{M}\p{N}]+/gu, '-') keeps the letters, marks, and numbers of every language.

Emoji, and why length can lie

length doesn’t count characters the way people do:

'Hi'.length // 2
'👋'.length // 2
'👨‍👩‍👧'.length // 8

JavaScript strings are made of UTF-16 code units. Most characters take one unit, but emoji take two, and the family emoji is really three emoji glued together with invisible joiner characters. In a real interface, that means:

  • A character counter on a bio field says 8 when someone typed a single emoji.
  • slice() can cut an emoji in half and leave a broken � at the end of your shortened text.
  • split('') breaks emoji into meaningless halves.

Spreading the string, [...text], splits it by code points instead, which fixes single emoji like 👋 but still counts the family as 5. To split text the way people actually see it, use Intl.Segmenter:

const segmenter = new Intl.Segmenter()
 
function countCharacters(text) {
  return [...segmenter.segment(text)].length
}
 
countCharacters('Hi 👋') // 4
countCharacters('👨‍👩‍👧') // 1

Intl.Segmenter works in all modern browsers. It’s what I’d use for character counters, and anywhere you cut text that people might fill with emoji.

The rest: concat, charCodeAt, String.raw, and toString

A few methods you’ll use less often, but should recognize.

concat() joins strings: 'Java'.concat('Script') gives you 'JavaScript'. Template literals do the same job and are easier to read, so you’ll rarely see concat() in modern code.

charCodeAt() returns the number behind a character, so 'J'.charCodeAt(0) is 74. It has a fun real-life use: giving every user a consistent avatar color. Add up the codes of their name and use the total to pick a hue:

function avatarHue(name) {
  let total = 0
  for (let i = 0; i < name.length; i++) {
    total += name.charCodeAt(i)
  }
  return total % 360
}
 
avatarHue('Hassib') // 242
avatarHue('Ella') // 22

Use it as hsl(242 70% 85%) behind the initials from earlier, and the same name always gets the same color, without storing anything.

String.raw is a tag for template literals that keeps backslashes exactly as written, which is handy for Windows paths and regular expressions:

`C:\Users\hassib\Desktop` // 'C:UsershassibDesktop'
String.raw`C:\Users\hassib\Desktop` // 'C:\Users\hassib\Desktop'

And toString() returns the string itself, which you’ll almost never need. The trap worth knowing sits right next to it: new String('hi') creates an object, not a string. typeof says 'object', and two of them are never equal, so new String('a') === new String('a') is false. When you need to turn a value into a string, call String(value) without new.

Putting it together: a searchable list in React

Let’s finish with something you might build this week: a search box that filters a list and highlights the matching part of each result.

function toSearchKey(text) {
  return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
}
 
function Highlight({ text, query }) {
  const key = toSearchKey(query)
  const index = toSearchKey(text).indexOf(key)
  if (!key || index === -1) return text
 
  return (
    <>
      {text.slice(0, index)}
      <mark>{text.slice(index, index + key.length)}</mark>
      {text.slice(index + key.length)}
    </>
  )
}
 
function SearchableList({ items }) {
  const [query, setQuery] = useState('')
  const search = query.trim()
  const results = items.filter((item) => toSearchKey(item).includes(toSearchKey(search)))
 
  return (
    <>
      <label>
        Search{' '}
        <input type="search" value={query} onChange={(event) => setQuery(event.target.value)} />
      </label>
 
      <ul>
        {results.map((item) => (
          <li key={item}>
            <Highlight text={item} query={search} />
          </li>
        ))}
      </ul>
 
      {results.length === 0 && <p>No results for “{search}”.</p>}
    </>
  )
}

A few details make this work well:

  • The input keeps exactly what the user typed. Trimming happens when the value is used, so typing a space between two words still works. That’s the trap from the trimming section.
  • Both sides go through the same toSearchKey(). That’s what makes the search ignore capitals and accents, so “creme” finds “Crème brûlée”.
  • The highlight slices the original text, not the search key, so results keep their capitals and accents. For typical text, removing the accents doesn’t change the number of characters, so the positions line up.
  • The empty state repeats the query. It’s a small touch, but it helps people notice a typo.

This is fast enough for a few hundred items. With thousands, look at useDeferredValue to keep typing smooth, or move the search to the server.

Quick reference

Here are the patterns from this article in one place:

What you wantCode
Remove spaces from both endstext.trim()
Compare without caring about casea.toLowerCase() === b.toLowerCase()
Get the last charactertext.at(-1)
Check if text contains somethingtext.includes('word')
Check how text starts or endspath.startsWith('/blog'), name.endsWith('.pdf')
Find a positiontext.indexOf('word')
Take part of a stringtext.slice(0, 10)
Turn a string into a listtext.split(',')
Replace every matchtext.replaceAll('old', 'new')
Add leading zerosString(7).padStart(2, '0')
Repeat a string'★'.repeat(4)
Find all matches safelytext.match(/#\w+/g) ?? []
Sort text properlylist.toSorted((a, b) => a.localeCompare(b))
Ignore accents in a searchtext.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
Count characters like a person[...new Intl.Segmenter().segment(text)].length

Wrapping up

Strings are where a lot of small interface bugs live: a space nobody can see, a search that cares about capital letters, an emoji that counts as two. The good news is that a handful of methods covers almost all of it. Trim input when you use it, compare in lowercase, reshape text with slice(), split(), and replaceAll(), reach for localeCompare() and normalize() for anything people read in their own language, and remember that length counts code units, not characters.

If you have questions, or you’re working on an interface and want a second pair of eyes, feel free to reach out on LinkedIn or through my contact page. I’m always happy to help.

Blog

More articles

Let’s build something great together

Tell me about your project, and let’s see how I can help your business grow.

What happens next

  1. 1Send me a message
  2. 2We talk it through on a free call
  3. 3You get a clear plan and quote

I reply within 24 hours.