JavaScript Strings and Template Literals

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Node.js 22 LTS

Almost every script touches strings: building output, slicing fields, cleaning input, generating markup. This chapter covers the methods that come up daily and the two or three semantics that trip people up.

Three kinds of quotes

const a = "double";
const b = 'single';
const c = `template literal`;

Double and single quotes are equivalent; pick whichever avoids escaping:

const text = 'He said "hello"';
const other = "It's fine";

Use backticks when you need interpolation or multiple lines. With plain quotes, a newline requires \n.

Template literals

Anything inside ${} is an expression:

const name = "ann";
const score = 95;

console.log(`${name} scored ${score}`);        // ann scored 95
console.log(`passed: ${score >= 60}`);         // passed: true
console.log(`$${(19.99 * 1.13).toFixed(2)}`);  // computed inline

Multi-line text needs no escaping, and indentation is preserved:

const html = `
<ul>
  <li>${name}</li>
</ul>
`;

Nesting template literals inside ${} is legal but hurts readability; extract a variable beyond one level.

Strings are immutable

let s = "hello";
s[0] = "H";
console.log(s);   // "hello"

The assignment silently does nothing (strict mode throws TypeError). Every “modifying” method returns a new string:

const s = "hello";
s.toUpperCase();              // "HELLO", discarded
const upper = s.toUpperCase();  // what you meant

The same applies to trim, replace, padStart, and the rest. String methods never mutate.

Length and indexing

const s = "hello";
s.length;      // 5
s[0];          // "h"
s.at(-1);      // "o"  (ES2022, negative indexing)

length counts UTF-16 code units, not characters:

"😀".length;            // 2
[... "😀"].length;      // 1

If you need user-perceived characters, convert with spread or Array.from(str) and work on the array. For grapheme clusters (emoji with modifiers, flags), use Intl.Segmenter.

Searching and testing

const s = "hello world";

s.includes("world");    // true
s.startsWith("he");     // true
s.endsWith("ld");       // true
s.indexOf("o");         // 4, or -1
s.lastIndexOf("o");     // 7

includes answers existence; indexOf gives the position. Case-insensitive checks need normalization:

s.toLowerCase().includes("WORLD".toLowerCase());

Slicing: slice versus substring

const s = "hello world";

s.slice(0, 5);      // "hello"
s.slice(6);         // "world"
s.slice(-5);        // "world"
s.substring(0, 5);  // "hello"

Differences:

  • slice accepts negative indices; substring treats them as 0.
  • slice returns "" when start > end; substring swaps the arguments.
  • slice is also the array method, so one habit covers both.

Use slice.

Replacing

const s = "a-b-c";

s.replace("-", "/");       // "a/b-c"   first match only
s.replace(/-/g, "/");      // "a/b/c"   regex with global flag
s.replaceAll("-", "/");    // "a/b/c"   ES2021

replace with a string argument changes only the first occurrence. This is the most common string bug in review. Use replaceAll or a /g regex.

In the replacement string, $&, $1, and $$ have special meanings. When you need a literal, pass a function:

s.replaceAll("-", () => "/");

Split and join

"a,b,c".split(",");        // ["a", "b", "c"]
"a,b,c".split(",", 2);     // ["a", "b"]
["a", "b"].join("-");      // "a-b"
["a", "b"].join("");       // "ab"

They are inverses, and together they cover most delimited-text work. For real CSV, use a parser: fields can contain the delimiter inside quotes.

Trimming and padding

"  hi  ".trim();          // "hi"
"  hi  ".trimStart();     // "hi  "
"  hi  ".trimEnd();       // "  hi"
"7".padStart(3, "0");     // "007"
"7".padEnd(3, "-");       // "7--"

trim removes spaces, tabs, and newlines at both ends. On user input it is almost always the first step, before validation.

Case and repetition

"Hello".toLowerCase();   // "hello"
"Hello".toUpperCase();   // "HELLO"
"-".repeat(3);           // "---"

Case-insensitive comparison is a normalize-then-compare:

const same = a.trim().toLowerCase() === b.trim().toLowerCase();

For locale-sensitive comparison, prefer localeCompare or Intl.Collator.

Worked example: normalizing a tag

function normalizeTag(raw) {
  return raw
    .trim()
    .toLowerCase()
    .replaceAll(/\s+/g, "-")
    .replaceAll(/[^a-z0-9-]/g, "");
}

normalizeTag("  Hello World  ");   // "hello-world"
normalizeTag("Node.js 22");        // "nodejs22"

The chain is readable because each step is a single-purpose method returning a new string.

Common mistakes

1. replace used for a global change

Only the first match changes. Use replaceAll or /g.

2. Treating length as a character count

Emoji and some CJK-adjacent characters take two code units. Spread the string when counting user-visible characters.

3. Trying to assign to str[0]

Strings are immutable; build a new string instead.

4. Negative indices with substring

They become 0. Use slice.

5. Building long strings with += in a hot loop

Each concatenation creates a new string. Collect parts in an array and join("").

6. Over-nesting template literals

Pull the inner part into a named variable; the resulting code is easier to review.

Summary

  • Template literals interpolate expressions and span lines.
  • Strings are immutable; methods return new values.
  • includes for existence, slice for ranges, replaceAll for global replacement.
  • split and join handle delimited text.
  • length counts code units, not characters.

Next: throwing, catching, and propagating errors.