Quick Comparison
| magic-regexp | regexp-tree | safe-regex | |
|---|---|---|---|
| Weekly downloads | ~500K | ~15M | ~10M |
| Purpose | Build readable regex | Parse / transform regex | Detect unsafe ReDoS patterns |
| TypeScript support | Yes (typed capture groups) | Yes | Yes |
| Use in app code | Yes | Rarely | Yes (validation layer) |
| Use in tooling | Sometimes | Primary use case | CI pipeline |
TL;DR
magic-regexp is the UnJS composable regex builder — write regex with TypeScript-first chainable API, get compile-time type safety for capture groups, no regex syntax needed. regexp-tree is the regex AST toolkit — parse, transform, optimize, and generate regular expressions using an abstract syntax tree. safe-regex detects vulnerable regex patterns — prevents ReDoS (Regular Expression Denial of Service) attacks by detecting exponential backtracking. In 2026: magic-regexp for building readable regex, regexp-tree for regex tooling, safe-regex for security auditing.
Key Takeaways
- magic-regexp: ~500K weekly downloads — UnJS, composable regex, typed capture groups
- regexp-tree: ~15M weekly downloads — regex parser/transformer/optimizer, used by ESLint
- safe-regex: ~10M weekly downloads — ReDoS detection, prevents catastrophic backtracking
- Completely different tools: build regex, transform regex, secure regex
- magic-regexp makes regex readable and type-safe
- safe-regex is critical for validating user-supplied patterns
Why Regex Tooling Exists
Writing a raw regex pattern is fast for simple cases and increasingly problematic as patterns grow. A pattern like /^(?:https?:\/\/)?(?:www\.)?([a-zA-Z0-9-]+(?:\.[a-zA-Z]{2,})+)(?:\/.*)?$/ does something immediately obvious to its author and almost completely opaque to anyone else — including the same author six months later. This isn't a beginner problem. Experienced developers routinely write regex patterns that become maintenance liabilities within weeks of being committed.
The tooling landscape exists to address three distinct problems with raw regex. The readability and maintainability problem is what magic-regexp solves: complex patterns are hard to write correctly, harder to review in pull requests, and hardest to modify without breaking existing behavior. The programmatic manipulation problem is what regexp-tree solves: developers building linters, codemods, or compatibility transformers need to reason about regex structure as data, not as opaque strings. The security problem is what safe-regex solves: a specific class of regex patterns can cause catastrophic performance degradation when given crafted input, and these vulnerable patterns look completely normal to human reviewers.
magic-regexp
magic-regexp — composable regex:
Building regex
import { createRegExp, exactly, oneOrMore, digit, letter, anyOf } from "magic-regexp"
// Instead of: /^\d{3}-\d{3}-\d{4}$/
const phoneRegex = createRegExp(
exactly(digit.times(3))
.and("-")
.and(digit.times(3))
.and("-")
.and(digit.times(4)),
["g"]
)
// Instead of: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
const emailRegex = createRegExp(
oneOrMore(anyOf(letter, digit, exactly("._%+-")))
.and("@")
.and(oneOrMore(anyOf(letter, digit, exactly(".-"))))
.and(".")
.and(letter.times.atLeast(2))
)
Named capture groups (type-safe)
import { createRegExp, exactly, oneOrMore, digit, anyOf } from "magic-regexp"
// Create regex with named groups:
const dateRegex = createRegExp(
exactly(digit.times(4)).as("year")
.and("-")
.and(digit.times(2)).as("month")
.and("-")
.and(digit.times(2)).as("day")
)
// TypeScript knows the capture groups:
const match = "2026-03-09".match(dateRegex)
if (match) {
match.groups.year // string — TypeScript infers this
match.groups.month // string
match.groups.day // string
}
Common patterns
import {
createRegExp, exactly, maybe, oneOrMore, digit, word,
charIn, charNotIn, anyOf, linefeed, tab,
} from "magic-regexp"
// URL slug: /^[a-z0-9]+(-[a-z0-9]+)*$/
const slugRegex = createRegExp(
oneOrMore(charIn("a-z0-9"))
.and(
exactly("-").and(oneOrMore(charIn("a-z0-9"))).times.any()
)
)
// Semver: /^(\d+)\.(\d+)\.(\d+)(-[\w.]+)?$/
const semverRegex = createRegExp(
oneOrMore(digit).as("major")
.and(".")
.and(oneOrMore(digit)).as("minor")
.and(".")
.and(oneOrMore(digit)).as("patch")
.and(maybe(exactly("-").and(oneOrMore(anyOf(word, exactly("."))))))
)
const match = "1.2.3-beta.1".match(semverRegex)
// match.groups.major → "1"
// match.groups.minor → "2"
// match.groups.patch → "3"
Composability for complex patterns
One of magic-regexp's strongest advantages is composability — the ability to break a complex pattern into named, reusable pieces that combine into a larger whole. This mirrors how good software design works in general: small pieces with clear responsibilities, composed rather than written monolithically.
import { createRegExp, exactly, oneOrMore, anyOf, maybe, charIn } from "magic-regexp"
// Define reusable components:
const domainLabel = oneOrMore(charIn("a-zA-Z0-9-"))
const tld = oneOrMore(charIn("a-zA-Z")).and(charIn("a-zA-Z")) // at least 2 chars
const domain = domainLabel.and(exactly(".").and(domainLabel).times.any()).and(exactly(".")).and(tld)
const protocol = maybe(anyOf("https://", "http://"))
const wwwPrefix = maybe(exactly("www."))
const path = maybe(exactly("/").and(oneOrMore(charIn("a-zA-Z0-9/_.-"))))
// Compose into a URL pattern:
const urlRegex = createRegExp(
protocol.and(wwwPrefix).and(domain.as("domain")).and(path.as("path"))
)
Compared to writing the equivalent raw regex, this version is readable in a code review without needing to mentally parse escape sequences, and it can be modified safely — adding an optional port number, for example, means adding a maybe(exactly(":").and(oneOrMore(digit))) between domain and path without touching the rest of the pattern.
Readability in code review
Regex patterns in code review are notoriously difficult to evaluate. Reviewers tend to approve them with a comment like "LGTM, looks reasonable" because mentally executing a complex pattern is time-consuming and error-prone. This means bugs in regex patterns often slip through review uncaught. magic-regexp patterns read more like prose and can be evaluated by someone who doesn't know regex syntax at all — a meaningful improvement to review quality for validation logic.
Limitations: no lookahead/lookbehind, compile-time vs runtime
magic-regexp has two meaningful limitations to know before committing to it. First, it does not support lookahead or lookbehind assertions ((?=...), (?!...), (?<=...), (?<!...)). These constructs are used in sophisticated patterns — for example, password validation that requires at least one digit without matching any specific character position — and magic-regexp has no equivalent builder API. If your pattern requires lookahead or lookbehind, you'll need to write the raw regex for that portion.
Second, magic-regexp builds regex at runtime (when the module loads), not at compile time. For the vast majority of applications this is inconsequential — pattern construction is a one-time cost per module load. But if you're building extremely performance-sensitive code that constructs regex in hot paths, or if you need a build-time transform that compiles magic-regexp patterns to raw regex strings for a bundle, the library doesn't currently provide that out of the box.
Why it matters
Traditional regex:
/^(?:https?:\/\/)?(?:www\.)?([a-zA-Z0-9-]+(?:\.[a-zA-Z]{2,})+)(?:\/.*)?$/
magic-regexp:
createRegExp(
maybe(anyOf("http://", "https://"))
.and(maybe("www."))
.and(oneOrMore(anyOf(letter, digit, exactly("-")))
.and(oneOrMore(exactly(".").and(letter.times.atLeast(2)))))
.as("domain")
.and(maybe(exactly("/").and(oneOrMore(charNotIn(" ")))))
)
Benefits:
✅ Readable — no cryptic symbols
✅ Type-safe — capture groups are typed
✅ Composable — build from smaller parts
✅ Maintainable — easy to modify
regexp-tree
regexp-tree — regex AST toolkit:
AST structure explained
When regexp-tree parses a pattern, it returns a tree of nodes where each node has a type property and type-specific children. Understanding the node types makes it much easier to write transforms:
RegExp— the root node, containsbody(the pattern) andflagsAlternative— a sequence of subpatterns matched left to rightDisjunction— the|operator, withleftandrightbranchesGroup— a capturing or non-capturing group, withcapturing: booleanandexpressionRepetition— a quantifier applied to anexpression(the+,*,?,{n,m}family)CharacterClass— a[...]character class with anexpressionsarrayChar— a single character, withkind(simple,meta,unicode, etc.) andvalueAssertion—^,$,\b, lookahead, lookbehind
import regexpTree from "regexp-tree"
// Parse a regex into an AST:
const ast = regexpTree.parse("/^[a-z]+\\d{2,4}$/i")
console.log(JSON.stringify(ast, null, 2))
// → {
// type: "RegExp",
// body: {
// type: "Alternative",
// expressions: [
// { type: "Assertion", kind: "^" },
// { type: "Repetition", expression: { type: "CharacterClass", ... }, quantifier: { type: "+", ... } },
// { type: "Repetition", expression: { type: "Char", value: "\\d" }, quantifier: { type: "{2,4}", ... } },
// { type: "Assertion", kind: "$" },
// ],
// },
// flags: "i",
// }
Optimize regex
import regexpTree from "regexp-tree"
// Optimize a regex:
const optimized = regexpTree.optimize("/[a-zA-Z0-9_]|[0-9]/")
console.log(optimized.toString())
// → /[\w]/ (simplified character class)
regexpTree.optimize("/(?:a|b|c)/").toString()
// → /[abc]/
regexpTree.optimize("/(a)(b)\\1\\2/").toString()
// → /(a)(b)\1\2/ (already optimal)
Transform regex
import regexpTree from "regexp-tree"
// Transform: convert named groups to numbered groups
const result = regexpTree.transform("/(?<year>\\d{4})-(?<month>\\d{2})/", {
NamedGroup(path) {
// Replace named groups with regular groups:
path.replace({
type: "Group",
capturing: true,
expression: path.node.expression,
})
},
})
console.log(result.toString())
// → /(\\d{4})-(\\d{2})/
Practical use in ESLint plugin development
regexp-tree is the right foundation for any ESLint plugin that needs to reason about regular expressions in source code. When ESLint parses a file with a regex literal like /[0-9]+/, the AST node for that literal contains its raw string value. regexp-tree gives you the second level of parsing: turning that string into a structured tree you can traverse and analyze.
import regexpTree from "regexp-tree"
// In an ESLint rule:
export const rule = {
create(context) {
return {
Literal(node) {
// Check if this is a regex literal:
if (!node.regex) return
const ast = regexpTree.parse(`/${node.regex.pattern}/${node.regex.flags}`)
// Traverse the regex AST to find issues:
regexpTree.traverse(ast, {
CharacterClass(path) {
// Detect [0-9] that could be \d:
const exprs = path.node.expressions
if (
exprs.length === 1 &&
exprs[0].type === "ClassRange" &&
exprs[0].from.value === "0" &&
exprs[0].to.value === "9"
) {
context.report({
node,
message: "Use \\d instead of [0-9]",
fix: (fixer) => fixer.replaceText(node, "/\\d/"),
})
}
},
})
},
}
},
}
Optimizer limitations
The optimizer handles a well-defined set of simplifications: redundant groups, character class merging, unnecessary quantifiers. It does not perform deep semantic analysis — it won't detect that (?:a|b|c|d|e) could be expressed as a character class, or that a complex alternation could be rewritten as a single quantifier. The optimization rules are correct and safe but not exhaustive. For production use in a build tool, test the optimizer output against your expected patterns and check for regressions.
safe-regex
safe-regex — ReDoS detection:
Understanding star height and why it predicts ReDoS risk
Star height is a formal property of regular expressions that measures the maximum nesting depth of the Kleene star (or any quantifier) in the pattern. A pattern with star height 0 has no quantifiers at all. A pattern with star height 1 has quantifiers but no quantifier nested inside another. A pattern with star height 2 has a quantifier inside another quantifier — this is the danger zone.
The intuition is simple: a quantifier says "try this subpattern zero or more times." When that subpattern itself contains a quantifier, the regex engine must try every combination of how many times the inner quantifier matches for every iteration of the outer quantifier. With overlapping alternatives, the combination space is exponential in the input length. safe-regex computes the star height of a pattern and flags anything with a star height of 2 or higher as potentially vulnerable.
import safe from "safe-regex"
// Check if a regex is safe from ReDoS:
safe(/^(a+)+$/) // false — exponential backtracking!
safe(/^([a-zA-Z0-9]+)$/) // true — safe
safe(/^(a|b|c)+$/) // true — safe
safe(/(a+){10}/) // false — nested quantifiers
safe(/\d+/) // true — safe
// With string:
safe("^(a+)+$") // false
// Adjust the star height limit:
safe(/^(a+)+$/, { limit: 25 }) // Default limit is 25
Why ReDoS matters
// ReDoS — Regular Expression Denial of Service:
// Vulnerable regex:
const emailRegex = /^([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+)\.([a-zA-Z]{2,})$/
// Malicious input that causes exponential backtracking:
const evil = "a".repeat(30) + "@" + "b".repeat(30) + "!"
// This could take MINUTES to process with a vulnerable regex
// The pattern /^(a+)+$/ with input "aaaaaaaaaaaaaaaaX":
// → Tries every possible way to divide "a"s between groups
// → 2^n combinations — exponential time
// → With 30 "a"s, could take hours
Node.js is particularly vulnerable to ReDoS because its V8 regex engine uses a backtracking approach, and JavaScript is single-threaded: one slow regex evaluation blocks the entire event loop, freezing all concurrent request processing until the match either completes or times out. A single malicious request can effectively take your server offline.
Validating user input patterns
import safe from "safe-regex"
// In a search API that accepts regex:
app.get("/api/search", (req, res) => {
const { pattern } = req.query
if (!safe(pattern)) {
return res.status(400).json({
error: "Regex pattern is potentially unsafe (ReDoS risk)",
})
}
const regex = new RegExp(pattern, "i")
const results = packages.filter((pkg) => regex.test(pkg.name))
res.json(results)
})
Integration into CI pipeline
safe-regex is most valuable as a static check run against your codebase's regex patterns before deployment. You can write a simple script that scans source files for regex literals and runs each through safe-regex:
// scripts/check-regex-safety.ts
import { readFileSync } from "fs"
import safe from "safe-regex"
// Crude regex literal extractor — in practice use a proper AST parser:
const REGEX_LITERAL = /\/([^/\\]|\\.)+\/[gimsuy]*/g
const files = process.argv.slice(2)
let hasUnsafe = false
for (const file of files) {
const source = readFileSync(file, "utf8")
for (const match of source.matchAll(REGEX_LITERAL)) {
const pattern = match[0]
if (!safe(pattern)) {
console.error(`Unsafe regex in ${file}: ${pattern}`)
hasUnsafe = true
}
}
}
if (hasUnsafe) process.exit(1)
Add this to your CI pipeline as a pre-merge check: npx ts-node scripts/check-regex-safety.ts src/**/*.ts. This catches newly introduced vulnerable patterns before they reach production, when they're easiest to fix.
Alternatives: re2 library for safe regex execution
For environments where you need to accept arbitrary user-supplied patterns (search UIs, custom filter interfaces), safe-regex validation is one layer of defense but not a complete solution — there's a theoretical possibility that a safe-regex-approved pattern is still slow enough to cause problems, and safe-regex's analysis is conservative rather than exhaustive.
The re2 npm package wraps Google's RE2 library, which is a regex engine designed to guarantee linear-time matching regardless of the pattern. RE2 does not backtrack. Any pattern that would cause exponential backtracking in V8 simply matches or doesn't match in O(n) time in RE2. The tradeoff is that RE2 does not support backreferences or lookahead/lookbehind assertions — features that require backtracking to implement. For most input validation use cases, this limitation doesn't matter, and linear-time guarantees are strictly safer than safe-regex's static analysis.
Common vulnerable patterns
Vulnerable (exponential backtracking):
/^(a+)+$/ — nested quantifiers
/^(a|aa)+$/ — overlapping alternation
/^([a-zA-Z]+)*$/ — quantified group with quantified content
/^(a+b?)*$/ — optional element in quantified group
/(.*a){x}/ — .* before specific char in group
Safe alternatives:
/^a+$/ — single quantifier (no nesting)
/^[a-zA-Z]+$/ — character class (no group needed)
/^(?:a|b)+$/ — non-overlapping alternation
/^\w+@\w+\.\w+$/ — simple character classes
Combining All Three in a Regex Processing Pipeline
A realistic application might use all three packages at different points in its lifecycle. Here's how they fit together:
During development, write new regex patterns using magic-regexp's composable API. Capture groups are typed, patterns are readable, and teammates can review them without being regex experts.
During CI, run safe-regex against all regex literals in the codebase to catch any vulnerable patterns introduced by this change. This runs as a static check alongside your TypeScript compilation step.
When building developer tooling — an ESLint plugin, a codemod, a documentation generator — use regexp-tree to parse, analyze, and transform the regex patterns that exist in other people's code.
In production, validate any user-supplied regex pattern (search filters, custom rules) with safe-regex or route it through re2 before executing it. Never run an unvalidated user-provided pattern against large inputs.
Performance: When Does Regex Performance Actually Matter?
For most Node.js applications, regex performance is not a bottleneck. A straightforward pattern executed against a short string is completed in microseconds. The V8 JIT compiler caches compiled regex patterns, so patterns created at module load time don't pay compilation cost on each invocation.
Regex performance becomes relevant in two specific scenarios. First: when a regex is applied to many strings in a hot loop, such as filtering thousands of log lines per second or validating every incoming HTTP request header. In these cases, simpler patterns (character classes instead of alternations, anchored patterns that fail fast) measurably outperform complex ones. Second: when a regex pattern is vulnerable to ReDoS and the input triggers backtracking — here performance doesn't degrade gradually, it collapses catastrophically. A pattern that processes 10,000 strings per second under normal inputs might process 0.001 strings per second against a crafted attack input.
The practical guidance is: don't optimize regex for performance unless you've profiled and confirmed it's a bottleneck. Do audit regex for ReDoS risk on any endpoint that handles user input, because the worst case isn't "slightly slow" but "server unresponsive."
Testing Regex Patterns Effectively
Unit testing regex patterns is straightforward but often skipped. A few test cases that should always be present: the happy path (inputs that should match), the negative path (inputs that should not match), edge cases (empty string, maximum-length valid input, unicode characters if relevant), and if the pattern is user-facing, at least one test that verifies a safe-regex check passes for the pattern.
import { describe, it, expect } from "vitest"
import safe from "safe-regex"
import { phoneRegex, emailRegex, slugRegex } from "./patterns"
describe("validation patterns", () => {
describe("phoneRegex", () => {
it.each([
["123-456-7890", true],
["800-555-0100", true],
["12-34-5678", false], // wrong format
["abc-def-ghij", false], // not digits
["", false],
])("matches %s → %s", (input, expected) => {
expect(phoneRegex.test(input)).toBe(expected)
})
it("is safe from ReDoS", () => {
expect(safe(phoneRegex)).toBe(true)
})
})
})
Feature Comparison
| Feature | magic-regexp | regexp-tree | safe-regex |
|---|---|---|---|
| Purpose | Build regex | Parse/transform regex | Detect unsafe regex |
| API | Composable builder | AST manipulation | Safety checker |
| TypeScript types | Yes (capture groups) | Yes | Yes |
| Parse regex | No | Yes | No |
| Build regex | Yes | Yes (from AST) | No |
| Optimize regex | No | Yes | No |
| Transform regex | No | Yes | No |
| ReDoS detection | No | No | Yes |
| Named groups | Yes (type-safe) | Yes (AST) | N/A |
| Used by | UnJS projects | ESLint, Babel | Security tools |
| Weekly downloads | ~500K | ~15M | ~10M |
When to Use Each
Use magic-regexp if:
- Want readable, composable regex without cryptic syntax
- Need type-safe capture groups in TypeScript
- Building regex that will be maintained by a team
- In the UnJS ecosystem
Use regexp-tree if:
- Building regex tooling (linters, codemods, analyzers)
- Need to parse, optimize, or transform regex programmatically
- Building an ESLint plugin that works with regex
- Need AST-level regex manipulation
Use safe-regex if:
- Accepting user-supplied regex patterns
- Auditing your codebase for ReDoS vulnerabilities
- Building a WAF or input validation layer
- Need to prevent catastrophic backtracking
ReDoS in Production: A Real Threat
Regular Expression Denial of Service (ReDoS) is a class of attack where carefully crafted input causes a regex engine to take exponential time to evaluate. Unlike buffer overflows or SQL injection, ReDoS vulnerabilities are subtle — the regex looks syntactically correct and works fine for normal inputs, but has a catastrophic worst case that can freeze an entire Node.js server for seconds or minutes when triggered.
The canonical example is ^(a+)+$. For the input "aaaaaaaaaaaaaaX", the engine attempts every possible way to partition the a characters among the nested groups, producing exponential backtracking. With 20 characters, the match might take milliseconds. With 30 characters, it takes seconds. With 40 characters, it takes minutes. The pattern looks innocuous because it works correctly for all valid inputs — the pathological behavior only appears with inputs designed to trigger maximum backtracking.
safe-regex detects these patterns by analyzing the star height of a regular expression. Any application that accepts user-provided patterns — search filters, custom validation rules, log query interfaces — should validate every pattern with safe-regex before compiling and using it.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on magic-regexp v0.8.x, regexp-tree v0.1.x, and safe-regex v2.x.
Compare regex utilities and developer tooling on PkgPulse →
See also: acorn vs @babel/parser vs espree, ohash vs object-hash vs hash-wasm, and destr vs secure-json-parse vs fast-json-parse.