bastianplsfix

Strings

A string is an immutable primitive. Every method returns a new string and none modifies the one you called it on, so the whole API is a collection of ways to produce text from other text.

Two things about strings live elsewhere in this series, and both are bigger than they look. What a string is made of, and why .length disagrees with what a reader counts, is text, and the three things called a character. Why the original is untouched when a method returns is the values-and-references page's a string method returns a new string. This entry is the practical middle: how to search text, take it apart, put it together, and turn things that are not text into text.

Create programs/strings.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assert, assertEquals, assertThrows } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

asking what a string contains

Deno.test("asking what a string contains", () => {
const path = "notes/2026/draft.md";

assert(path.includes("2026"));
assert(path.startsWith("notes/"));
assert(path.endsWith(".md"));
assertEquals(path.indexOf("/"), 5);
assertEquals(path.lastIndexOf("/"), 10);
assertEquals(path.indexOf("missing"), -1);
});
Check programs/strings.test.ts
running 1 test from ./programs/strings.test.ts
asking what a string contains ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

Two families of question.

  1. includes, startsWith, and endsWith answer yes or no, and their booleans are safe in any condition.
  2. indexOf and lastIndexOf answer with a position, and with -1 for not-found: the in-band marker whose cost the sentinels page priced in the old in-band markers, and what each costs. if (path.indexOf("x")) reads fine and is wrong twice over, because -1 is truthy and position 0 is falsy. That trap is what includes exists to prevent: reach for the boolean methods when the answer is a yes or no, and keep indexOf for when you genuinely need the position.

taking a piece out

Deno.test("taking a piece out", () => {
const path = "notes/2026/draft.md";

assertEquals(path.slice(0, 5), "notes");
assertEquals(path.slice(-3), ".md");
assertEquals(path.at(-1), "d");
assertEquals(path.at(99), undefined);
});
taking a piece out ... ok (0ms)

slice is the one to learn: start and end positions, with negatives counting from the end. at is newer and exists so a negative index reads naturally for a single position, and past the end it answers undefined, the language's absence from the nothing-twice page. Both count in code units, so neither is safe on text you did not construct, for the reasons truncating by index cuts characters in half demonstrated on the text page.

putting one together

+ and += work, with the both-jobs caveat the assignment page recorded in += inherits both jobs of +, and template literals are the readable choice for mixing text and values. For a list, there is a third way worth knowing:

Deno.test("putting one together", () => {
const items = ["tooth brush", "passport"];

assertEquals(items.join(", "), "tooth brush, passport");
assertEquals(["a"].join(", "), "a");
assertEquals([].join(", "), "");
});
putting one together ... ok (0ms)

Collect into an array and join. The three assertions are the three cases hand-written loop concatenation usually gets wrong: the separator goes between elements only, a single element gets no separator at all, and an empty list produces empty text rather than a stray comma.

shaping one

Deno.test("shaping one", () => {
assertEquals("7".padStart(3, "0"), "007");
assertEquals("yes".padEnd(6, "!"), "yes!!!");
assertEquals("#".padStart(5, "oak"), "oako#");

assertEquals("\t oak\n ".trim(), "oak");
assertEquals(" oak ".trimStart(), "oak ");
assertEquals(" oak ".trimEnd(), " oak");

assertEquals("*".repeat(3), "***");
assertEquals("*".repeat(0), "");
});
shaping one ... ok (0ms)

Three shapers, one subtlety each.

  1. The pad argument is a target length, not a count of characters to add: "7".padStart(3, "0") produces three characters total, and a multi-character filler is cut off at the target rather than overshooting, which is why "#".padStart(5, "oak") ends in o#.
  2. trim takes whitespace off both ends, tabs and newlines included, and trimStart/trimEnd take one end each.
  3. repeat accepts zero and produces the empty string, which makes it safe in calculated indentation.

substring is not slice

substring looks interchangeable with slice, and older code uses them as if they were. Hand both a backwards range and predict:

Deno.test("substring is not slice", () => {
const text = "planet";

assertEquals(text.slice(4, 1), "");
assertEquals(text.substring(4, 1), "");
});
Check programs/strings.test.ts
running 5 tests from ./programs/strings.test.ts
...
substring is not slice ... FAILED (8ms)

ERRORS

substring is not slice => ./programs/strings.test.ts:45:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- lan
+

FAILURES

substring is not slice => ./programs/strings.test.ts:45:11

FAILED | 4 passed | 1 failed (10ms)

error: Test failed

substring(4, 1) answered "lan". It noticed the arguments were backwards and silently swapped them. Record both differences:

Deno.test("substring is not slice", () => {
const text = "planet";

assertEquals(text.slice(4, 1), "");
assertEquals(text.substring(4, 1), "lan");

assertEquals(text.slice(-2), "et");
assertEquals(text.substring(-2), "planet");
});
substring is not slice ... ok (0ms)

substring swaps backwards arguments, and clamps a negative index to zero, so substring(-2) returns the whole string. Both behaviors turn a calculation that produced wrong numbers into a plausible-looking result instead of an empty string, which is the opposite of helpful: the bad range is the bug, and slice's empty answer is the symptom that gets it found. Use slice, and let a bad range give you nothing.

replace does one, replaceAll does all

Replace every dash. Predict, then save:

Deno.test("replace does one, replaceAll does all", () => {
assertEquals("a-a-a".replace("-", "+"), "a+a+a");
});
Check programs/strings.test.ts
running 6 tests from ./programs/strings.test.ts
...
replace does one, replaceAll does all ... FAILED (8ms)

ERRORS

replace does one, replaceAll does all => ./programs/strings.test.ts:55:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- a+a-a
+ a+a+a

FAILURES

replace does one, replaceAll does all => ./programs/strings.test.ts:55:11

FAILED | 5 passed | 1 failed (10ms)

error: Test failed

a+a-a: replace with a string replaces the first occurrence only, the default nobody expects. Correct the prediction and add the two alternatives, plus the guard between them:

Deno.test("replace does one, replaceAll does all", () => {
assertEquals("a-a-a".replace("-", "+"), "a+a-a");
assertEquals("a-a-a".replaceAll("-", "+"), "a+a+a");
assertEquals("a-a-a".replace(/-/g, "+"), "a+a+a");

assertThrows(
() => "a-a".replaceAll(/-/, "+"),
TypeError,
"non-global RegExp",
);
});
replace does one, replaceAll does all ... ok (0ms)

Before replaceAll existed, the workaround was a global regular expression, which is why so much older code turns a plain string into a regex only to replace text. And replaceAll handed a non-global regex throws rather than quietly doing one replacement, a refusal in the same spirit as the whole entry: loud beats plausible.

the replacement string has its own syntax

The second argument to replace is not literal text:

Deno.test("the replacement string has its own syntax", () => {
assertEquals("cat".replace("a", "$&$&"), "caat");
assertEquals("cat".replace(/(c)(a)/, "$2$1"), "act");
assertEquals("cost".replace("c", "$$"), "$ost");

const fromUser = "$&$&$&";
assertEquals("cat".replace("a", fromUser), "caaat");
assertEquals("cat".replace("a", () => fromUser), "c$&$&$&t");
});
the replacement string has its own syntax ... ok (0ms)

Walk the syntax, then the trap.

  1. $& inserts the match itself, so replacing a with $&$& doubles it. $1 and friends insert capture groups, which is how $2$1 swaps two letters. $$ is how you write one literal dollar sign.
  2. Useful when you want it, and a defect when you did not: fromUser is data, and passed as a replacement string it expanded itself, one match becoming three. That is a small injection, and any replacement that came from outside your program can do it.
  3. The fix is small too: pass a function. A function's return value is inserted literally, with no $ handling at all, so it is the safe default whenever the replacement is data rather than a constant you typed.

changing case is not a character mapping

Two assumptions to give up, one per half:

Deno.test("changing case is not a character mapping", () => {
assertEquals("ß".toUpperCase(), "SS");
assertEquals("ß".length, 1);
assertEquals("ß".toUpperCase().length, 2);

assertEquals("I".toLowerCase(), "i");
assertEquals("I".toLocaleLowerCase("tr"), "ı");
assertEquals("i".toLocaleUpperCase("tr"), "İ");
});
changing case is not a character mapping ... ok (0ms)

First, case conversion does not preserve length: the German ß uppercases to two letters, SS. Second, it is not the same everywhere: Turkish has a dotted and a dotless i and treats them as different letters, so lowercasing I correctly gives ı there and i everywhere else.

Together they are the reason case-insensitive comparison by lowercasing is a bug in general: it either uses the wrong locale or ignores locale entirely. Compare with a collator built with sensitivity: "base", from the ordering and sorting page's numeric: true reads digits as numbers, and keep toUpperCase for display.

splitting an empty string is not an empty list

Split an empty line into fields. Predict how many fields arrive:

Deno.test("splitting an empty string is not an empty list", () => {
assertEquals("a,b,c".split(","), ["a", "b", "c"]);
assertEquals("".split(","), []);
});
Check programs/strings.test.ts
running 9 tests from ./programs/strings.test.ts
...
splitting an empty string is not an empty list ... FAILED (8ms)

ERRORS

splitting an empty string is not an empty list => ./programs/strings.test.ts:87:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- [
- "",
- ]
+ []

FAILURES

splitting an empty string is not an empty list => ./programs/strings.test.ts:87:11

FAILED | 8 passed | 1 failed (11ms)

error: Test failed

One empty field, not zero fields. split found no separator, so it returned everything it was given as a single piece, and everything it was given was "". That is consistent, and it is almost never what the code downstream assumes. Correct the prediction, and note the limit argument while here:

Deno.test("splitting an empty string is not an empty list", () => {
assertEquals("a,b,c".split(","), ["a", "b", "c"]);
assertEquals("a,b,c".split(",", 2), ["a", "b"]);
assertEquals("".split(","), [""]);
});
splitting an empty string is not an empty list ... ok (0ms)

Anything that splits input should decide what an empty input means before it counts the pieces.

four ways to make a string, each failing differently

String(v), a template literal, v.toString(), and JSON.stringify(v) are not four spellings of one operation. Each has values it cannot handle, and two of them fail in exactly opposite places:

Deno.test("four ways to make a string, each failing differently", () => {
const symbol = Symbol("s");
assertEquals(String(symbol), "Symbol(s)");
assertThrows(() => "" + (symbol as unknown as string), TypeError);

const bare: Record<string, never> = Object.create(null);
assertEquals(JSON.stringify(bare), "{}");
assertThrows(() => String(bare), TypeError);

assertEquals(JSON.stringify(undefined), undefined);
assertEquals(JSON.stringify(symbol), undefined);
assertEquals(JSON.stringify(() => {}), undefined);

function toDisplayString(value: unknown): string {
if (typeof value === "bigint") return `${value}n`;
return JSON.stringify(value) ?? String(value);
}
assertEquals(toDisplayString({ a: 1 }), '{"a":1}');
assertEquals(toDisplayString(undefined), "undefined");
assertEquals(toDisplayString(10n), "10n");
});
four ways to make a string, each failing differently ... ok (0ms)

Walk the failure map.

  1. A symbol converts explicitly and refuses concatenation, from the symbols page's converting one is deliberately awkward: turning a collision-proof key into a collidable string silently is the bug being prevented.
  2. The mirror image: Object.create(null) builds an object with no prototype, so it has no toString to answer with, and every primitive conversion throws, while JSON.stringify walks its properties without needing one.
  3. JSON.stringify sometimes hands back no string at all: undefined for undefined, symbols, and functions, the value rather than the text "undefined", extending the table from the nothing-twice page's JSON keeps null and loses undefined. Code that writes JSON.stringify(value).slice(0, 80) crashes the first time it meets a function.
  4. Since no single approach covers everything, toDisplayString composes them: JSON.stringify first, because it renders objects usefully rather than as [object Object]; String as the fallback, because it handles exactly the values JSON declines; and bigint by hand, because it is the one value that makes JSON.stringify throw rather than return nothing, from the numbers page's bigint keeps exactness, and keeps to itself.

For diagnostics rather than display, prefer Deno.inspect, or pass the value to console.log and let it render, which belongs to the console reference.

In practice