JavaScript Strings: Template Literals, String Methods and Immutability
From quote types and template literals to at(), slice, includes, replaceAll, split and padStart, we learn how strings work, why they are immutable, and the Turkish İ and emoji length traps.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
In the post on loops we walked through the letters of a string one by one with for...of. This time we focus on the string itself. Before we start, look at these four lines:
let language = "javascript";
language[0] = "J";
language.toUpperCase();
console.log(language);
What does it print to the console? "Javascript", "JAVASCRIPT", or something else entirely? Make a guess before you keep reading. We'll reveal the answer in the section where we talk about why strings never change.
Three Kinds of Quotes
In JavaScript you can write a string in three ways: single quotes, double quotes and backticks. Paste this into your browser's console and try it:
const single = 'Hello';
const double = "Hello";
const backtick = `Hello`;
console.log(single === double, double === backtick); // true true
All three produce the same value. There is no difference at all between single and double quotes; teams usually pick one and stick with it across the whole project. The real difference shows up once variables get involved. In the old days we glued text together piece by piece with +:
const userName = "Ada";
const age = 28;
console.log("Hello " + userName + ", you are " + age + " years old.");
It works, but counting spaces and quotes wears your eyes out. Template literals, which arrived with ES2015, made this much more readable. Inside backticks you can put an expression wherever you write ${...}; the engine evaluates that expression and drops the result into the text:
const userName = "Ada";
const age = 28;
console.log(`Hello ${userName}, you are ${age} years old.`);
console.log(`Next year you'll be ${age + 1}.`);
console.log(`Status: ${age >= 18 ? "adult" : "child"}`);
The curly braces aren't just for variable names. They can hold an addition, the ternary operator we saw earlier, even a function call. The second trick up a template literal's sleeve is multi-line text. Wherever you press Enter in the code, the text really does break to a new line:
const poem = `First line
Second line
Third line`;
console.log(poem);
Escape Characters
So what if you want a double quote inside a double-quoted string? Or a line break inside a regular string? That's where the backslash (\) comes in. A backslash tells the engine "don't read the next character literally, look at its special meaning":
console.log("She told me \"tomorrow\"."); // She told me "tomorrow".
console.log('It\'s fine'); // It's fine
console.log("Line 1\nLine 2"); // two separate lines
console.log("Name:\tAda"); // a tab in between
console.log("C:\\Users\\ada"); // C:\Users\ada
These are the ones you'll use most: \n for a new line, \t for a tab, \" and \' for quotes, and \\ for the backslash itself. In practice you rarely need to escape quotes. If a piece of text contains double quotes, you write it with single quotes or backticks and move on.
Length and Accessing Characters
Picture a string as a row of numbered boxes. Each box holds one character, and the numbering starts at zero. length tells you how many boxes there are, and square brackets take you to a specific box:
const word = "lemon";
console.log(word.length); // 5
console.log(word[0]); // "l"
console.log(word[4]); // "n"
console.log(word[10]); // undefined
console.log(word[word.length - 1]); // "n"
length is a property, not a method, so no parentheses at the end. Looking up an index that doesn't exist doesn't throw an error; you just get undefined. The word[word.length - 1] pattern we used for the last character is long and tiring to read. This is exactly where the at() method, added in ES2022, helps. Give it a negative number and it counts from the end:
const word = "lemon";
console.log(word.at(0)); // "l"
console.log(word.at(-1)); // "n"
console.log(word.at(-2)); // "o"
console.log(word[-1]); // undefined
The last line matters. Square brackets know nothing about negative indexes; they look for a box literally named "-1", don't find one, and return undefined. If you want to count from the end, use at().
Searching Inside a String
Checking whether a piece of text contains another piece is one of the most common everyday tasks. Four methods handle it:
const email = "ada@example.com";
console.log(email.includes("@")); // true
console.log(email.startsWith("ada")); // true
console.log(email.endsWith(".com")); // true
console.log(email.indexOf("@")); // 3
console.log(email.indexOf("#")); // -1
includes, startsWith and endsWith answer a yes/no question and return true or false. indexOf answers "where?": it returns the index where the piece you're looking for starts, or -1 if it can't find it. All of them are case-sensitive; to them, "Ada" and "ada" are two different things.
Give yourself a simple rule: if you only want to know whether something is there, use includes; reach for indexOf only when you actually need the position. We'll see why this matters in the pitfalls section.
Cutting, Cleaning and Formatting
To take part of a string you use slice(start, end). The start is included, the end is not; leave out the end and it goes all the way. Pass a negative value and it counts from the end:
const lang = "JavaScript";
console.log(lang.slice(0, 4)); // "Java"
console.log(lang.slice(4)); // "Script"
console.log(lang.slice(-6)); // "Script"
console.log(lang.slice(4, 0)); // ""
console.log(lang.substring(0, 4)); // "Java"
console.log(lang.substring(4, 0)); // "Java"
At first glance substring looks like slice's twin, but there are two differences: it treats negative numbers as zero, and if the start is greater than the end it quietly swaps them. In the same situation slice returns an empty string. Its behavior is more predictable, so prefer slice in everyday code.
Leading and trailing spaces in user input are a constant headache. trim() removes both; for just the start or just the end there are trimStart() and trimEnd(). The opposite need comes up often too: padding a piece of text to a certain length. That's what padStart and padEnd, added in ES2017, are for:
console.log(" ada@example.com ".trim()); // "ada@example.com"
console.log("7".padStart(3, "0")); // "007"
console.log(`${"5".padStart(2, "0")}:30`); // "05:30"
console.log("Invoice".padEnd(12, ".") + "$120"); // "Invoice.....$120"
For upper and lower case there are toUpperCase() and toLowerCase(). These two hide a subtlety when you work with Turkish text; we'll get to it in the pitfalls section.
Replacing and Splitting
To swap a piece of the text for something else, you use replace. But be careful, it's lazier than you might think:
const text = "cat, dog, cat";
console.log(text.replace("cat", "bird")); // "bird, dog, cat"
console.log(text.replaceAll("cat", "bird")); // "bird, dog, bird"
When you give replace a string, it changes only the first match. If you want to change all of them, there's replaceAll, added in ES2021. Before that you had to write a regular expression; we'll cover regex separately in an upcoming post.
split breaks a string at the separator you give it and returns the pieces inside an array:
console.log("apple,pear,banana".split(",")); // ["apple", "pear", "banana"]
console.log("code".split("")); // ["c", "o", "d", "e"]
console.log("one two three".split(" ")); // ["one", "two", "", "three"]
Notice the empty string in the last example. There's nothing between the two spaces, so JavaScript put an empty piece there. We'll go into arrays in detail in an upcoming post; for now it's enough to know that split turns text into a list.
Strings Are Immutable
Back to the opening question. The answer is "javascript". The first letter didn't change, and the letters didn't become uppercase either.
The reason is a rule we saw earlier in the post on data types: a string is a primitive, and primitives are immutable. Think of a string as words carved into stone. You can read them, you can take a photo and edit the photo however you like, but the stone itself never changes. The line language[0] = "J" is like trying to carve a new letter into the stone; the engine silently ignores the request. In strict mode it doesn't stay silent and throws a TypeError.
Methods like toUpperCase() don't touch the original string either. They create a new string and return it; they hand you the photo. If you don't store that returned value in a variable, the new string is simply forgotten. Here's the right way:
let language = "javascript";
language = language[0].toUpperCase() + language.slice(1);
console.log(language); // "Javascript"
const loud = language.toUpperCase();
console.log(loud); // "JAVASCRIPT"
console.log(language); // "Javascript"
The same rule applies to every method in this post: slice, trim, replace, padStart, toUpperCase. None of them changes the string you have; they all give you a new one. If you want to use the result, assign it to a variable.
Common Pitfalls
Putting indexOf straight into a condition
const file = "report.pdf";
if (file.indexOf("report")) {
console.log("Report found");
} else {
console.log("No report"); // this is the line that runs!
}
"report" sits at the very start of the text, so its index is 0. Remember the falsy list we saw earlier: 0 is falsy. On top of that, -1, which means "not found," is truthy. So this condition says the exact opposite of what you meant. For a yes/no question, use includes.
The Turkish İ and ı
console.log("istanbul".toUpperCase()); // "ISTANBUL"
console.log("istanbul".toLocaleUpperCase("tr-TR")); // "İSTANBUL"
console.log("IŞIK".toLowerCase()); // "işik"
console.log("IŞIK".toLocaleLowerCase("tr-TR")); // "ışık"
toUpperCase and toLowerCase follow language-independent rules: they turn the dotted i into a dotless I, and the I into a dotted i. In Turkish that means "ISTANBUL" instead of "İSTANBUL", and "işik" instead of "ışık" (light). Some languages have their own casing rules; Turkish and Azerbaijani are the classic examples. For text in those languages that users will see, pass the locale to toLocaleUpperCase and toLocaleLowerCase.
Emoji and length
console.log("a".length); // 1
console.log("😀".length); // 2
let count = 0;
for (const char of "😀") {
count++;
}
console.log(count); // 1
Internally, JavaScript stores strings as UTF-16 code units, and length counts those units, not characters. Most letters fit into a single unit, but some characters, like emoji, take up two. The for...of loop we saw earlier walks a string by its real characters, which is why it counts 1. With combined emoji, like skin tones or family emoji, even that isn't enough. For now, just keep this in mind: length is not always the number of characters you see on screen.
A Small Experiment
★☆☆ Start with const fullName = " ada lovelace ";. First trim the spaces, then split it in two with split(" "), capitalize the first letter of each piece and use a template literal to produce "Ada Lovelace".
★★☆ Predict before running: what do "JavaScript".slice(-6, -3), "JavaScript".substring(-6, 3) and "JavaScript".at(-11) return? Then check in the console, and if you got one wrong, figure out why.
★★☆ Write one line that masks a phone number: given "05321234567", it should output "*******4567". The slice and padStart pair does the job; use length instead of typing the length by hand.
★★★ Count the words in a sentence: for " JavaScript is fun " the answer should be 3. trim and split get you part of the way; you'll need one of the loops we saw earlier to filter out the empty pieces caused by multiple spaces.
In this post we cut and shaped text, but the "28" coming from a form is also a string, and you can't do math with it. In the next post we move on to the number type and the Math object: how to turn a string into a number, how to generate random numbers, and why 0.1 + 0.2 doesn't come out to exactly 0.3.