How to Get String Between 2 Characters in TypeScript

You can get string between 2 characters in TypeScript with indexOf() and slice(): find the start character, search for the end character from just after it, and slice the text in between. Here’s the core of it:

const from = text.indexOf("[") + 1;
const between = text.slice(from, text.indexOf("]", from));

Nothing below is typed from memory: I saved each example as a .ts file, compiled it in strict mode and ran it on TypeScript 7.0.2 and Node.js v22.22.2.

We’ll cover same and different delimiters, a safe helper that returns undefined, multi-character delimiters, the lazy regex /\[(.*?)\]/, every match with matchAll(), and nested brackets.

Get the string between two characters with indexOf() and slice()

indexOf() returns the position of a character, and slice(start, end) copies everything from start up to, but not including, end. The trick is the second argument of indexOf(), which tells it where to start searching:

const invoice: string = "Invoice [INV-2026-0142] for Austin, TX: $1,249.50";

const openAt = invoice.indexOf("[");
const closeAt = invoice.indexOf("]", openAt + 1);
console.log("[ at", openAt, "and ] at", closeAt);
console.log("between:", invoice.slice(openAt + 1, closeAt));

// different delimiters, and the end one also appears BEFORE the start one
const cookie: string = "city=Austin;state=TX;zip=78701";
const s = cookie.indexOf("state=") + "state=".length;
console.log("wrong:", JSON.stringify(cookie.slice(s, cookie.indexOf(";"))));
console.log("right:", cookie.slice(s, cookie.indexOf(";", s)));

Output:

[ at 8 and ] at 22
between: INV-2026-0142
wrong: ""
right: TX

The cookie line shows why that second argument matters. A plain indexOf(";") finds the semicolon after Austin, which sits before the start, and slice() quietly returns an empty string when the end index is smaller than the start.

If you only need everything after one character, there’s a shorter route in my guide on how to get the string after a character in TypeScript.

What if both delimiters are the same character?

Quotes, pipes and slashes open and close with the same character. Search for the second one starting one position past the first, or you’ll just find the first one again:

const shipping: string = 'Shipped to "Portland, OR" on 2026-10-22';
const q1 = shipping.indexOf('"');
const q2 = shipping.indexOf('"', q1 + 1);
console.log("quotes:   ", shipping.slice(q1 + 1, q2));

const row: string = "2026-10-22|Seattle|WA|$84.99";
const firstPipe = row.indexOf("|");
console.log("next pipe:", row.slice(firstPipe + 1, row.indexOf("|", firstPipe + 1)));
console.log("last pipe:", row.slice(firstPipe + 1, row.lastIndexOf("|")));
console.log("split():  ", row.split("|")[1]);

Output:

quotes:    Portland, OR
next pipe: Seattle
last pipe: Seattle|WA
split():   Seattle

Notice the difference between “next” and “last”. lastIndexOf() grabs everything up to the final pipe, which is right for a path but wrong for a CSV-style row like this one.

When the delimiter is a separator, split() is often the cleanest answer, as I show in splitting a string and getting the last element. If you want the quotes gone rather than the text inside, see removing quotes from a string in TypeScript.

A safe getBetween() helper for missing delimiters

Real input is messy, and indexOf() returns -1 when a character isn’t there. I wrap the logic in a helper that returns undefined instead of guessing:

function getBetween(text: string, start: string, end: string): string | undefined {
  const from = text.indexOf(start);
  if (from === -1) return undefined;            // start delimiter missing
  const begin = from + start.length;            // works for "[" and "{{" alike
  const to = text.indexOf(end, begin);          // look for end AFTER start
  if (to === -1) return undefined;              // end delimiter missing
  return text.slice(begin, to);
}

const samples: string[] = [
  "Invoice [INV-2026-0142] for Austin, TX",
  "Invoice INV-2026-0143 for Tampa, FL",
  "Invoice [INV-2026-0144 for Boise, ID",
  "Invoice [] for Reno, NV",
];
for (const text of samples) {
  console.log(JSON.stringify(getBetween(text, "[", "]")));
}

const plain = samples[1];
console.log("naive one-liner:", JSON.stringify(plain.slice(plain.indexOf("[") + 1, plain.indexOf("]"))));
console.log("with a fallback:", getBetween(plain, "[", "]") ?? "UNKNOWN");

Output:

"INV-2026-0142"
undefined
undefined
""
naive one-liner: "Invoice INV-2026-0143 for Tampa, F"
with a fallback: UNKNOWN
Command Prompt output of a TypeScript getBetween helper returning the text between brackets, undefined for missing delimiters and junk from the naive one-liner
Found, missing start, missing end, and empty brackets, followed by the junk the naive one-liner returns.

Look at the naive line. With no brackets, indexOf("[") + 1 becomes 0 and indexOf("]") becomes -1, so slice(0, -1) returns the whole string minus its last letter. No error, just wrong data.

Because the return type is string | undefined, strict mode makes callers handle the miss. Use ?? for a default or an if check before calling string methods.

An empty string is a valid result ([]), and it’s different from undefined. For a yes/no test before extracting, see checking if a string contains a substring in TypeScript.

Get a substring between multi-character delimiters

Tags like <b>, template tokens like {{ and markers like BEGIN: are more than one character long. Adding 1 to the start index then leaves part of the delimiter behind:

function getBetween(text: string, start: string, end: string): string | undefined {
  const from = text.indexOf(start);
  if (from === -1) return undefined;            // start delimiter missing
  const begin = from + start.length;            // works for "[" and "{{" alike
  const to = text.indexOf(end, begin);          // look for end AFTER start
  if (to === -1) return undefined;              // end delimiter missing
  return text.slice(begin, to);
}

const html: string = "<b>Chicago</b>, IL";
const at = html.indexOf("<b>");
console.log("+1 bug:     ", html.slice(at + 1, html.indexOf("</b>")));
console.log("+tag.length:", html.slice(at + "<b>".length, html.indexOf("</b>")));

console.log(getBetween("Hi {{firstName}}, your total is {{total}}", "{{", "}}"));
console.log(getBetween("2026-10-22 09:14 BEGIN:order 1042 approved:END", "BEGIN:", ":END"));

Output:

+1 bug:      b>Chicago
+tag.length: Chicago
firstName
order 1042 approved

The helper avoids this because it adds start.length, not 1. It only finds the first pair, though. Those {{ }} tokens often live in template strings, which I cover in creating a multiline string in TypeScript.

Extract text between brackets with a regex

match() with a capture group does the search and slice in one step. In /\[(.*?)\]/, the brackets are escaped, and the parentheses capture what’s between them. Use the result unchecked and strict TypeScript stops you:

const ticket: string = "Ticket [HD-5531] opened in Phoenix, AZ";
const m = ticket.match(/\[(.*?)\]/);
console.log(m[1].toLowerCase());

Output:

between_strict.ts(3,13): error TS18047: 'm' is possibly 'null'.
Command Prompt showing the TypeScript compiler error TS18047 'm' is possibly 'null' after match() on a string
match() returns RegExpMatchArray | null, so m[1] won’t compile until you handle null.

Optional chaining, ?.[1], fixes it and gives undefined when there’s no match. Here’s how lazy, greedy and character-class patterns behave:

const line: string = "Invoice [INV-2026-0142] for [Austin, TX]";
console.log("lazy:   ", line.match(/\[(.*?)\]/)?.[1]);
console.log("greedy: ", line.match(/\[(.*)\]/)?.[1]);
console.log("class:  ", line.match(/\[([^\]]*)\]/)?.[1]);
console.log("missing:", "no brackets here".match(/\[(.*?)\]/)?.[1]);

const label: string = "Ship to [Suite 400\nDallas, TX]";
console.log("no s flag:", label.match(/\[(.*?)\]/)?.[1]);
console.log("s flag:   ", JSON.stringify(label.match(/\[(.*?)\]/s)?.[1]));

Output:

lazy:    INV-2026-0142
greedy:  INV-2026-0142] for [Austin, TX
class:   INV-2026-0142
missing: undefined
no s flag: undefined
s flag:    "Suite 400\nDallas, TX"

The ? in .*? makes the match lazy, so it stops at the first ]. Greedy .* runs to the last one. [^\]]* means “anything except a closing bracket” and is the most explicit option.

Also note that . doesn’t match a line break unless you add the s flag. For splitting text on patterns rather than extracting, see splitting a string by regex in TypeScript.

Get all strings between two characters with matchAll()

match() only returns the first capture. To get every value between brackets, use matchAll() with the g flag; each result carries the capture and its index:

const order: string = "Order [1042] shipped to [Denver, CO] for [$318.40] on [2026-10-22]";

const values = Array.from(order.matchAll(/\[(.*?)\]/g), (m) => m[1]);
console.log(values);

for (const m of order.matchAll(/\[(.*?)\]/g)) {
  console.log(`index ${m.index}: ${m[1]}`);
}

try {
  const noG = /\[(.*?)\]/;
  console.log(Array.from(order.matchAll(noG)));
} catch (err) {
  console.log((err as Error).name + ":", (err as Error).message);
}

Output:

[ '1042', 'Denver, CO', '$318.40', '2026-10-22' ]
index 6: 1042
index 24: Denver, CO
index 41: $318.40
index 54: 2026-10-22
TypeError: String.prototype.matchAll called with a non-global RegExp argument
Command Prompt output of TypeScript matchAll returning every value between square brackets with its index, then a TypeError for a regex without the g flag
Four values with their positions, then the TypeError matchAll() throws when the g flag is missing.

That error only shows up at runtime; the compiler accepts any RegExp. Array.from() turns the iterator into a plain string[], and a value like $318.40 still needs converting the string to a number before you do math.

When the values sit between different separators instead of matched pairs, splitting a string with multiple separators may be simpler.

Build the regex from variables safely

If the delimiters come from a variable, escape them before passing them to new RegExp(). A dot, a dollar sign or a parenthesis has a special meaning inside a pattern:

function escapeRegExp(text: string): string {
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function betweenAll(text: string, start: string, end: string): string[] {
  const re = new RegExp(`${escapeRegExp(start)}(.*?)${escapeRegExp(end)}`, "g");
  return Array.from(text.matchAll(re), (m) => m[1]);
}

const version: string = "v2.14.7";
console.log("unescaped:", JSON.stringify(version.match(new RegExp("." + "(.*?)" + "."))?.[1]));
console.log("escaped:  ", betweenAll(version, ".", "."));

console.log(betweenAll("Total $84.99 (tax $6.93) (shipping $5.00)", "(", ")"));
console.log(betweenAll("Hello {{first}} {{last}} from {{city}}", "{{", "}}"));

Output:

unescaped: ""
escaped:   [ '14' ]
[ 'tax $6.93', 'shipping $5.00' ]
[ 'first', 'last', 'city' ]

Unescaped, each . means “any character”, so the pattern matches v2 and captures nothing. Escaped, it finds 14. For version strings, splitting a string by dot in TypeScript is another option.

Node.js 24 and newer browsers also ship a built-in RegExp.escape(), but it isn’t in Node.js 22, so the small helper above is the portable choice.

Nested and unbalanced brackets

Regex can’t count. With nested parentheses, the lazy pattern stops at the first ) and greedy runs to the last one, and both are wrong for some inputs. A depth counter gets the outer pair right:

function balancedBetween(text: string, openCh: string, closeCh: string): string | undefined {
  const begin = text.indexOf(openCh);
  if (begin === -1) return undefined;
  let depth = 0;
  for (let i = begin; i < text.length; i++) {
    if (text[i] === openCh) depth++;
    else if (text[i] === closeCh && --depth === 0) return text.slice(begin + 1, i);
  }
  return undefined;                              // opened but never closed
}

const formula: string = "ROUND(SUM(B2:B9) * 1.0825, 2)";
console.log("lazy regex:", formula.match(/\((.*?)\)/)?.[1]);
console.log("balanced:  ", balancedBetween(formula, "(", ")"));

const two: string = "SUM(B2:B9) + MAX(C2:C9)";
console.log("greedy:    ", two.match(/\((.*)\)/)?.[1]);
console.log("balanced:  ", balancedBetween(two, "(", ")"));

console.log("unbalanced:", balancedBetween("ROUND(SUM(B2:B9) * 1.0825, 2", "(", ")"));

Output:

lazy regex: SUM(B2:B9
balanced:   SUM(B2:B9) * 1.0825, 2
greedy:     B2:B9) + MAX(C2:C9
balanced:   B2:B9
unbalanced: undefined
Command Prompt comparing a lazy regex, a greedy regex and a depth-counting TypeScript function on nested parentheses in spreadsheet formulas
The balanced function returns the full outer argument, handles two groups side by side, and returns undefined when a parenthesis is never closed.

The function walks forward from the first opening character, adds one for each opener and subtracts one for each closer. It returns as soon as the depth drops back to zero and returns undefined if it never does.

It works for single-character pairs like (), [] and {}. For full JSON or HTML, use a real parser instead. And if you’re doing that ROUND math in code, see rounding to 2 decimals in TypeScript.

Which method should you use?

SituationBest methodWatch out for
One pair, known charactersindexOf(end, from) + slice()-1 when a delimiter is missing
Same opening and closing characterSecond indexOf() from first + 1lastIndexOf() grabs too much
Multi-character delimitersAdd start.lengthAdding 1 leaves part of the tag
First match, short codematch(/\[(.*?)\]/)?.[1]Greedy .* and null
Every matchmatchAll() with gTypeError without the g flag
Nested pairsDepth counterUnclosed input returns undefined

Here are a few more string guides that pair well with this one:

Frequently asked questions

How do I get the string between two characters in TypeScript?

Find the start with indexOf(), find the end with indexOf(end, start + 1), then call slice() between them. Check both indexes for -1 first.

How do I get the text between square brackets?

Use text.match(/\[(.*?)\]/)?.[1]. The lazy .*? stops at the first closing bracket, and you get undefined when there are no brackets.

How do I get every value between brackets, not just the first?

Use Array.from(text.matchAll(/\[(.*?)\]/g), (m) => m[1]). The regex must have the g flag or matchAll() throws a TypeError.

Why does my substring include part of the delimiter?

You added 1 to the start index for a delimiter longer than one character. Add the delimiter’s length instead.

What does slice() return if the end index is before the start?

An empty string. That’s why searching for the end character from the start position matters when it also appears earlier.

Where are indexOf() and slice() documented?

See String.prototype.slice() on MDN, which links to indexOf() and the other string methods.