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.
includes,startsWith, andendsWithanswer yes or no, and their booleans are safe in any condition.indexOfandlastIndexOfanswer with a position, and with-1for 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-1is truthy and position0is falsy. That trap is whatincludesexists to prevent: reach for the boolean methods when the answer is a yes or no, and keepindexOffor 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.
- 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 ino#. trimtakes whitespace off both ends, tabs and newlines included, andtrimStart/trimEndtake one end each.repeataccepts 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.
$&inserts the match itself, so replacingawith$&$&doubles it.$1and friends insert capture groups, which is how$2$1swaps two letters.$$is how you write one literal dollar sign.- Useful when you want it, and a defect when you did not:
fromUseris 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. - 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.
- 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.
- The mirror image:
Object.create(null)builds an object with no prototype, so it has notoStringto answer with, and every primitive conversion throws, whileJSON.stringifywalks its properties without needing one. JSON.stringifysometimes hands back no string at all:undefinedforundefined, symbols, and functions, the value rather than the text"undefined", extending the table from the nothing-twice page's JSON keepsnulland losesundefined. Code that writesJSON.stringify(value).slice(0, 80)crashes the first time it meets a function.- Since no single approach covers everything,
toDisplayStringcomposes them:JSON.stringifyfirst, because it renders objects usefully rather than as[object Object];Stringas the fallback, because it handles exactly the values JSON declines; andbigintby hand, because it is the one value that makesJSON.stringifythrow rather than return nothing, from the numbers page'sbigintkeeps 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
- Use
includes,startsWith, andendsWithfor questions; never testindexOffor truthiness. - Use
sliceinstead ofsubstring. - Prefer
replaceAllto a global regular expression, and use a replacement function when the replacement is data. - Build lists with
join, and mix text with values using a template literal. - Treat case conversion as display behavior, not a comparison strategy.
- Convert arbitrary display data with
JSON.stringifyand aStringfallback; useDeno.inspectorconsole.logfor debugging, never a bare+ "".