Skip to main content
JavaScript · lesson 8 of 24
JavaScript beginner Lesson 8 of 24

Working with Strings in JavaScript

Learn how to create, manipulate, and transform strings in JavaScript using template literals, built-in methods, and regular expressions.

By SkillByExample · Updated

Strings are immutable sequences of UTF-16 code units — every “modifying” method returns a new string. This lesson covers creating them, the everyday methods, regular expressions and the Unicode surprises. Every example is a complete file run with node.

Creating Strings

Single quotes, double quotes and backticks all make strings; backslash escapes work in each.

const single = 'It\'s single';
const double = "She said \"hi\"";
const template = `Both ' and " are fine here`;
const path = "C:\\Users\\alice";

console.log(single, "|", double, "|", template);
console.log(path, path.length);
console.log("col1\tcol2\nrow2");
console.log(typeof "text", typeof String(42), String(null), `${[1, 2]}`);

const word = "cat";
word[0] = "b";
console.log(word, word.toUpperCase(), word);
$ node create.js
It's single | She said "hi" | Both ' and " are fine here
C:\Users\alice 14
col1	col2
row2
string string null 1,2
cat CAT cat

"C:\\Users\\alice" is 14 characters — each \\ is one backslash. String(value) and template literals convert anything to a string (an array joins with commas). The last line shows immutability: assigning to word[0] does nothing, and toUpperCase() returned a new string, leaving word unchanged.

Template Literals

Backticks allow ${expression} interpolation and literal line breaks.

const name = "Alice";
const score = 94.456;
const items = ["apples", "oranges"];

console.log(`Hello, ${name}! Score: ${score.toFixed(1)} (${score >= 60 ? "pass" : "fail"})`);

const html = `
<ul>
  ${items.map((item) => `<li>${item}</li>`).join("\n  ")}
</ul>`.trim();
console.log(html);

console.log(`${{ a: 1 }}`, `${null}`, `${undefined}`);
$ node template.js
Hello, Alice! Score: 94.5 (pass)
<ul>
  <li>apples</li>
  <li>oranges</li>
</ul>
[object Object] null undefined

Any expression works inside ${}, including another template literal. Newlines and indentation inside the backticks are kept exactly, which is why the list is built with .join("\n ") and the whole thing .trim()med. Interpolating an object gives the useless [object Object] — convert it yourself (JSON.stringify) first.

Searching and Extracting

const s = "The quick brown fox jumps over the lazy dog";

console.log(s.includes("fox"), s.startsWith("The"), s.endsWith("dog"));
console.log(s.indexOf("the"), s.lastIndexOf("the"), s.indexOf("cat"));
console.log(s.toLowerCase().indexOf("the"));

const str = "JavaScript";
console.log(str.slice(0, 4), str.slice(4), str.slice(-6), str.slice(4, -2));
console.log(str.at(0), str.at(-1), str[-1], str[99]);
console.log(str.substring(4, 0), str.slice(4, 0) === "");
$ node search.js
true true true
31 31 -1
0
Java Script Script Scri
J t undefined undefined
Java true

Searches are case-sensitive: indexOf("the") found the lowercase one at 31, not “The” at 0 — lowercase both sides for a case-insensitive search. A missing substring is -1. slice(start, end) excludes end and accepts negative indices; at(-1) gets the last character, while str[-1] is just undefined. The older substring quietly swaps reversed arguments, which hides bugs — prefer slice.

Transforming

const raw = "  Hello, World!  ";
console.log(`[${raw.trim()}] [${raw.trimStart()}] [${raw.trimEnd()}]`);

console.log("abc".repeat(3), "5".padStart(4, "0"), "42".padEnd(6, "."));
console.log("one,two,three".split(","), "one,two,three".split(",", 2));
console.log("abc".split(""), "abc".split(), "".split(","));

for (const [label, value] of [["apples", 3], ["kiwis", 12], ["bananas", 150]]) {
  console.log(label.padEnd(10, ".") + String(value).padStart(5));
}
$ node transform.js
[Hello, World!] [Hello, World!  ] [  Hello, World!]
abcabcabc 0005 42....
[ 'one', 'two', 'three' ] [ 'one', 'two' ]
[ 'a', 'b', 'c' ] [ 'abc' ] [ '' ]
apples....    3
kiwis.....   12
bananas...  150

padStart/padEnd are what you want for zero-padded ids and aligned columns. Note "".split(",") returns [""] — one empty string, not an empty array — a classic cause of “why is there a blank row?” bugs when splitting empty CSV lines.

Replacing

With a string pattern, replace changes only the first match.

const text = "I like cats. Cats are great. cats!";

console.log(text.replace("cats", "dogs"));
console.log(text.replaceAll("cats", "dogs"));
console.log(text.replace(/cats/gi, "dogs"));
console.log("hello world".replace(/\b\w/g, (c) => c.toUpperCase()));
console.log("2024-07-04".replace(/(\d+)-(\d+)-(\d+)/, "$3/$2/$1"));
console.log("price: $5".replace("$5", "$$10"));
$ node replace.js
I like dogs. Cats are great. cats!
I like dogs. Cats are great. dogs!
I like dogs. dogs are great. dogs!
Hello World
04/07/2024
price: $10

replaceAll with a string is exact and case-sensitive, so Cats survived; the /gi regex replaced all three. A function replacement receives each match. In a replacement string, $1…$9 insert capture groups and $$ is a literal dollar — so beware user-supplied replacement text, where $ has special meaning.

Regular Expressions

A regex describes a pattern. test asks “does it match?”, match extracts, and named groups make extraction readable.

const emailRe = /^[\w.+-]+@[\w-]+(\.[\w-]+)*\.[a-z]{2,}$/i;
for (const e of ["user@example.com", "a.b+tag@mail.co.uk", "nope@", "x@y.z"]) {
  console.log(e.padEnd(20), emailRe.test(e));
}

const text = "Call 555-1234 or 555-5678, not 5551234";
console.log(text.match(/\d{3}-\d{4}/g), text.match(/\d{3}-\d{4}/).index);

const { groups } = "Due: 2024-07-04".match(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/);
console.log(groups);

for (const m of "a=1, b=22, c=333".matchAll(/(\w)=(\d+)/g)) {
  console.log(m[1], "->", Number(m[2]), "at", m.index);
}
$ node regex.js
user@example.com     true
a.b+tag@mail.co.uk   true
nope@                false
x@y.z                false
[ '555-1234', '555-5678' ] 5
[Object: null prototype] { y: '2024', m: '07', d: '04' }
a -> 1 at 0
b -> 22 at 5
c -> 333 at 11

Without the g flag, match returns the first match with details such as .index; with g, just the matched strings. matchAll gives you the details for every match, capture groups included. The email regex rejected x@y.z because of its [a-z]{2,} top-level-domain rule — regexes are best for “roughly the right shape”; the only real email validation is sending a confirmation email.

Tagged Templates

A tag function receives the literal pieces and the interpolated values separately, so it can transform the values before assembly — for instance, escaping HTML.

function html(strings, ...values) {
  const escape = (v) =>
    String(v).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
  return strings.reduce((out, part, i) => out + part + (i < values.length ? escape(values[i]) : ""), "");
}

function show(strings, ...values) {
  return JSON.stringify({ strings, values });
}

const userInput = '<script>alert("xss")</script>';
console.log(show`<p>${userInput} and ${42}</p>`);
console.log(html`<p>User said: ${userInput}</p>`);
$ node tagged.js
{"strings":["<p>"," and ","</p>"],"values":["<script>alert(\"xss\")</script>",42]}
<p>User said: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</p>

strings always has one more element than values — the pieces before, between and after the interpolations. The html tag escapes only the values, leaving the trusted literal markup alone, which is how libraries such as lit-html make templates safe by default.

Unicode: length Isn’t “Number of Characters”

length counts UTF-16 code units. Emoji and many other characters take two, and some visible “characters” are several code points combined.

const samples = ["hello", "café", "😀", "👍🏽", "👨‍👩‍👧"];
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });

for (const s of samples) {
  console.log(
    s.padEnd(8),
    "length:", String(s.length).padEnd(3),
    "code points:", String([...s].length).padEnd(3),
    "graphemes:", [...seg.segment(s)].length,
  );
}

console.log("😀".slice(0, 1), "😀".slice(0, 1).length, JSON.stringify("😀".slice(0, 1)));
console.log("café" === "cafe\u0301", "café".normalize() === "cafe\u0301".normalize());
$ node unicode.js
hello    length: 5   code points: 5   graphemes: 5
café     length: 4   code points: 4   graphemes: 4
😀       length: 2   code points: 1   graphemes: 1
👍🏽     length: 4   code points: 2   graphemes: 1
👨‍👩‍👧 length: 8   code points: 5   graphemes: 1
� 1 "\ud83d"
false true

A family emoji is one visible character, five code points (three people joined by zero-width joiners) and eight UTF-16 units. Spreading a string splits by code point, which is better than length but still wrong for combined emoji; Intl.Segmenter counts what a user sees. Slicing through the middle of a surrogate pair produces a broken half-character (\ud83d). And two strings that look identical can differ in how an accent is encoded — normalise before comparing user input.

Real-World Helpers

const slugify = (text) =>
  text.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
    .toLowerCase().trim()
    .replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-|-$/g, "");

const truncate = (str, max) => (str.length <= max ? str : str.slice(0, max - 1).trimEnd() + "…");

const parseQuery = (search) => Object.fromEntries(new URLSearchParams(search));

const titleCase = (str) => str.replace(/\b\w/g, (c) => c.toUpperCase());

console.log(slugify("  Hello, World! -- Crème Brûlée_Recipe "));
console.log(truncate("The quick brown fox jumps", 20));
console.log(parseQuery("?page=2&sort=asc&q=caf%C3%A9+au+lait&tag=a&tag=b"));
console.log(titleCase("the dark knight rises"), "|", titleCase("don't stop"));
$ node helpers.js
hello-world-creme-brulee-recipe
The quick brown fox…
{ page: '2', sort: 'asc', q: 'café au lait', tag: 'b' }
The Dark Knight Rises | Don'T Stop

slugify first decomposes accented letters (é → e + accent) and strips the accents, so “Crème Brûlée” becomes creme-brulee rather than losing letters. URLSearchParams decoded %C3%A9 and + for you — but with a repeated key (tag), Object.fromEntries keeps only the last value; use params.getAll("tag") when repeats matter. The simple titleCase capitalises after any word boundary, including the apostrophe in “Don’T”.

Key Takeaways

  • Strings are immutable; methods return new strings.
  • slice and at accept negative indices; searches are case-sensitive.
  • replace with a string pattern changes only the first match — use replaceAll or a g regex.
  • matchAll + named groups is the readable way to extract data.
  • length counts UTF-16 units; use Intl.Segmenter to count visible characters, and normalize() before comparing.

Practice

1. Mask all but the last four digits of a card number, keeping it the same length.
const mask = (card) => card.slice(-4).padStart(card.length, "*");
console.log(mask("4111111111111111"));
console.log(mask("1234"));
$ node p1.js
************1111
1234

slice(-4) keeps the tail and padStart rebuilds the original length.

2. Count the vowels in a sentence with a regex.
const countVowels = (s) => (s.match(/[aeiou]/gi) ?? []).length;
console.log(countVowels("The quick brown fox"));
console.log(countVowels("rhythm"));
$ node p2.js
5
0

match returns null, not [], when there’s no match — the ?? [] avoids a TypeError on "rhythm".

3. Reverse a string without breaking emoji.
const naive = (s) => s.split("").reverse().join("");
const better = (s) => [...s].reverse().join("");
const text = "abc😀";
console.log(naive(text), JSON.stringify(naive(text)));
console.log(better(text));
$ node p3.js
��cba "\ude00\ud83dcba"
😀cba

split("") splits the emoji’s surrogate pair and reverses the halves into garbage; spreading splits by code point. (For combined emoji like 👍🏽, use Intl.Segmenter segments instead.)

4. Parse "key=value; key2=value2" cookie-style text into an object with matchAll.
const header = "theme=dark; lang=en-GB;  session=abc123";
const cookies = Object.fromEntries(
  [...header.matchAll(/\s*([^=;]+)=([^;]*)/g)].map(([, k, v]) => [k, decodeURIComponent(v)]),
);
console.log(cookies);
$ node p4.js
{ theme: 'dark', lang: 'en-GB', session: 'abc123' }

The leading \s* absorbs the spaces after each ;, so keys come out clean.

Frequently Asked Questions

Are JavaScript strings mutable?
No. Strings are immutable — every method that appears to modify a string actually returns a new string. The original is never changed.
What is the difference between slice and substring?
Both extract a portion of a string, but slice accepts negative indices (counting from the end), while substring treats negative values as 0. Prefer slice in most cases.
When should I use a template literal instead of string concatenation?
Almost always. Template literals are more readable, support multi-line strings naturally, and let you embed any expression without breaking the string apart.