Strings

Build, search and reshape text — including text that is not English.

04of 32
≈ 11Min read
9Parts

Template literals

Quotes are for fixed text. The moment a value goes in, use backticks — it reads better than concatenation and there is no way to forget a space.

the default way to build a string
const name = "Painted";
const days = 5;

`${name} — ${days} days`
`Total: ${(price * 1.18).toFixed(2)}`

`line one
line two`                       // newlines are kept

`${days > 7 ? "slow" : "quick"}`  // any expression

Slicing

slice is the one to learn
const s = "Concealed";

s.slice(0, 3)     // "Con"    — end is exclusive
s.slice(-3)       // "led"    — negatives count from the end
s.slice(3)        // "cealed"
s.at(-1)          // "d"      — the last character
s[0]              // "C"
s.length          // 9
slice, not substring

substring swaps its arguments if the first is larger and treats negatives as 0 — two surprises slice does not have.

Searching

Use includes when you want a yes or no. Use indexOf only when you need the position — the !== -1 dance is a habit from before includes existed.

boolean answers first
s.includes("ceal")      // true
s.startsWith("Con")     // true
s.endsWith("ed")        // true
s.indexOf("ceal")       // 3, or -1 when absent
s.lastIndexOf("e")      // 7
s.search(/\d+/)         // the index of a regex match, or -1
s.match(/(\w+)-(\w+)/)  // the match with its groups, or null

Replacing

the trap is in the first one
"a-b-c".replace("-", "+")      // "a+b-c"  — only the FIRST
"a-b-c".replaceAll("-", "+")   // "a+b+c"
"a-b-c".replace(/-/g, "+")     // "a+b+c"  — a global regex does it too

// the replacement can be a function
"5 doors".replace(/\d+/, (m) => Number(m) * 2);   // "10 doors"

Splitting and joining

string to array and back
"a,b,c".split(",")          // ["a","b","c"]
"a,b,c".split(",", 2)       // ["a","b"]
"hello".split("")           // ["h","e","l","l","o"] — breaks emoji
[...new Intl.Segmenter().segment("héllo")]  // safe for every script

["a","b"].join(" · ")       // "a · b"
A character is not a code unit

"👍".length is 2, and split("") tears an emoji in half. Use [...str] for code points, and Intl.Segmenter when you need what a reader would call a character.

Trimming and padding

formatting
"  a  ".trim()          // "a"
"  a  ".trimStart()     // "a  "
String(7).padStart(2, "0")    // "07"
"x".repeat(3)                 // "xxx"

Case, and why toLowerCase is not enough

localeCompare is also how you get a case-insensitive comparison that works in every language: a.localeCompare(b, undefined, { sensitivity: "base" }) === 0.

comparing text properly
"Painted".toLowerCase()               // "painted"

// sorting: a byte comparison puts "ə" and "ç" in the wrong place
["çay", "alma", "əl"].sort();                      // wrong for Azerbaijani
["çay", "alma", "əl"].sort((a, b) => a.localeCompare(b, "az"));   // right

// comparing: two visually identical strings can differ in bytes
"é" === "é"                               // may be false
"é".normalize() === "é".normalize()       // true

Escaping

inside a string literal
"She said \"no\""     // escaped quotes
'It\'s here'          // or just use the other quote
`Both " and ' are fine`
"line\nbreak"
"tab\there"
"a backslash: \\"

Building HTML safely

the one rule
// NEVER do this with anything a person typed
el.innerHTML = `<p>${userText}</p>`;

// either set text
el.textContent = userText;

// or escape first
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
  ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));

el.innerHTML = `<p>${esc(userText)}</p>`;
This is the whole of cross-site scripting

A template literal does not escape anything. If the value came from a person, a URL or an API, it has to be escaped or set as text.

Practice