A zero-dependency regular expression transform for partial matching, enabling validation of incomplete input strings against regex patterns.
Unlike C/C++ (via PCRE/PCRE2, RE2, Boost.Regex), Python (via third party regex module) or Java (via hitEnd), Javascript has no canonical / innate partial-matching for regular expressions.
This library transforms regular expressions to best-effort support partial matching, allowing you to test if an incomplete string could potentially match the full pattern. This is particularly useful for real-time input validation, autocomplete systems, progressive form validation, stream chunk matching, etc.
As a side effect of the parse this requires, each PartialMatchRegExp also exposes a features set naming the syntactic constructs its pattern uses — useful for consumers that need to reason about a pattern without writing their own regex parser. For many features, a simple search in the source would be insufficient.
Based on an algorithm created by Lucas Trzesniewski, re-created for NPM via ISC license, with permission.
npm install regex-partial-matchimport PartialMatchRegExp from "regex-partial-match";
const pattern = /^hello world/;
const partial = new PartialMatchRegExp(pattern);
partial.test("h"); // true - could match
partial.test("hello"); // true - could match
partial.test("hello world"); // true - full match
partial.test("goodbye"); // false - cannot matchtest() and exec() answer "could this match?", which is true for a prefix and for a complete match alike. hitEnd() reports whether the engine reached the end of the input while producing the match, so more input could still change it 1. Testing the original pattern instead doesn't answer that 2. Together they give progressive validation its three states:
import PartialMatchRegExp, { hitEnd } from "regex-partial-match";
const partial = new PartialMatchRegExp(/^\d{4}-\d{2}-\d{2}/);
function state(input) {
const match = partial.exec(input);
if (match === null) return "invalid";
return hitEnd(partial, match) ? "incomplete" : "complete";
}
state("20xx"); // 'invalid' - reject
state("2024"); // 'incomplete' - no error, keep typing
state("2024-06"); // 'incomplete' - no error, keep typing
state("2024-06-15"); // 'complete' - accept, enable submitNote
hitEnd() answers "could more input change this match?", not "is this text a match?". /hello \w+/ matches "hello world" in full, and hitEnd() is still true: \w+ read the end of the input looking for more. For an exact-length pattern like the date above the two questions coincide, which is what makes the three states work; a validator for an open-ended pattern that wants "valid so far" should test the original pattern as well.
If your environment doesn't tree-shake (e.g. Deno, or unbundled Node) and you have no use for hitEnd, import PartialMatchRegExp from its own subpath instead of the default entry, to avoid pulling in hitEnd's code:
import PartialMatchRegExp from "regex-partial-match/partialMatchRegExp";import "regex-partial-match/extend";
const partial = /^hello world/.toPartialMatchRegex();
partial.test("hel"); // trueThe library wraps each atomic element in a non-capturing group with a disjunction to the true end of the input, so the pattern matches any prefix of what the original would match:
/abc/ → /(?:a|$(?![\s\S]))(?:b|$(?![\s\S]))(?:c|$(?![\s\S]))/See How It Works for the full transform, where it over-accepts, how ^ is handled in groups and under the m flag, and how backreferences are matched.
- 🔤 Literal characters
- 🔣 Character escapes (
\n,\t,\x61,\u0061,\u{1F600}) - 🧩 Character class escapes:
/\w+/,/\d{3}/ - 🌐 Unicode character class escape (
\p{Letter},\P{Letter}) - 📋 Character classes (
[abc],[^abc],[a-z]) - 🧮 Unicode sets (
vflag) (/[\p{Lowercase}&&\p{Script=Greek}]/v) - 🔢 Quantifiers (
*,+,?,{n},{n,},{n,m}) - 🔀 Disjunction (
a|b) - 👥 Groups (capturing and non-capturing) (
(?:abc),(abc),(?<named>abc)) - 🔙 Backreferences (
\1,\k<name>) (See caveats for known limitations) - 👉 Lookahead assertions (
(?=...),(?!...)) - 👈 Lookbehind assertions (
(?<=...),(?<!...)) - ⚓ Input Boundaries (
^,$) - 🆒 Word Boundaries (
\b,\B) - 🏴 Flags:
g,i,m,s,u,d,y(See caveats fory) - 🎚️ Modifiers (
(?ims:...),(?-ims:...),(?im-s:...))
The following regex features are not currently supported:
⚠️ Character class substrings (\q{abc}) - When used independently, rather than to modify, can be included, but can't partially match. See caveats.
The library is compiled to ES2015 (ECMAScript 6). Certain regular expression features naturally require newer environments:
- Unicode property escapes (
\p{...},\P{...}) - ES2018+ - Lookbehind assertions (
(?<=...),(?<!...)) - ES2018+ - Named capturing groups (
(?<name>...)) - ES2018+ s(dotAll) flag - ES2018+d(hasIndices) flag - ES2022+v(unicodeSets) flag - ES2024+- Modifiers (
(?ims:...),(?-ims:...),(?i-ms:...)) - ES2025+
Each of these applies only when the original pattern uses the feature — everything else, including construction, exec() and test(), holds to the ES2015 floor. hitEnd() is the one exception: it always requires ES2018+, regardless of the pattern, since its internal probe uses named capturing groups.
See Caveats for details, examples and workarounds.
.test()and empty matches at the end of the input: an unanchored pattern always matches""at the end of the input; anchor with^, or askhitEnd()- Backreferences: some backreferences stay atomic
- Positive lookbehinds: must match in their entirety
- Surrogate pair matching: only whole astral characters match
- Sticky flag (
y): not suited to progressive input validation - "String properties": match atomically
import PartialMatchRegExp from "regex-partial-match";
const emailPattern = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
const partial = new PartialMatchRegExp(emailPattern);
function validateEmail(input) {
return partial.test(input) ? "valid" : "invalid";
}
validateEmail("user"); // 'valid' - could become valid
validateEmail("user@"); // 'valid' - could become valid
validateEmail("user@example"); // 'valid' - could become valid
validateEmail("user@example.com"); // 'valid' - complete match
validateEmail("@@invalid"); // 'invalid' - cannot matchimport PartialMatchRegExp from "regex-partial-match";
const commandPattern = /^(help|quit|save|load)/;
const partial = new PartialMatchRegExp(commandPattern);
function getSuggestions(input) {
return partial.test(input) ? "valid prefix" : "no suggestions";
}
getSuggestions("h"); // 'valid prefix'
getSuggestions("hel"); // 'valid prefix'
getSuggestions("help"); // 'valid prefix'
getSuggestions("xyz"); // 'no suggestions'import PartialMatchRegExp from "regex-partial-match";
// Process streaming data with pattern matching at chunk boundaries
const pattern = /\{"[^"]+":"[^"]+"\}/; // Match JSON objects
const partial = new PartialMatchRegExp(pattern);
let buffer = "";
function processChunk(chunk) {
buffer += chunk;
const matches = [];
// Extract complete matches
let match;
while ((match = pattern.exec(buffer))) {
matches.push(match[0]);
buffer = buffer.slice(match.index + match[0].length);
}
// Discard buffer if it cannot possibly complete
if (buffer && !partial.test(buffer)) {
buffer = "";
}
return matches;
}
processChunk('{"na'); // [] - partial, buffer: '{"na'
processChunk('me":"Jo'); // [] - partial, buffer: '{"name":"Jo'
processChunk('hn"}{"age":'); // ['{"name":"John"}'] - buffer: '{"age":'
processChunk("25}"); // ['{"age":25}'] - buffer: ''
processChunk("invalid{"); // [] - discarded, buffer: ''Useful for parsing log files, network streams, or any chunked data where records may be split across boundaries.
Extends RegExp. An instance behaves like a normal RegExp; instanceof RegExp is true, and .test(), .exec(), .match(), .matchAll(), .replace() etc. all work as expected, but also matches any input string that is a valid prefix of the original pattern, in addition to full matches.
Available via the default entry point of the package, or via regex-partial-match/partialMatchRegExp; see A note on Tree-Shaking.
Parameters:
pattern- ARegExpinstance, or a pattern source string (as accepted by theRegExpconstructor)flags- A flags string, used only whenpatternis a string (as accepted by theRegExpconstructor)
Returns:
- A
PartialMatchRegExpinstance that matches partial strings of the original pattern
When using import 'regex-partial-match/extend', this method is added to RegExp.prototype.
Returns:
- A new
PartialMatchRegExpthat matches partial strings, created from theRegExpinstance the method was called on.
Reports whether the engine reached the end of the input while producing match, so that more input could change it — the contract of the JDK's Matcher.hitEnd(): when it returns false, no continuation of the input changes the match's index or text. exec() alone cannot say: it returns the same shape of array for "h", "hello" and "hello world" against /hello world/.
Available as a named export of the default entry point: import { hitEnd } from 'regex-partial-match'. Its probe requires ES2018+, so in an environment that doesn't tree-shake and has no use for it, import PartialMatchRegExp from regex-partial-match/partialMatchRegExp instead of the default entry to avoid bundling hitEnd's code; see A note on Tree-Shaking.
Parameters:
partial- ThePartialMatchRegExpinstance that producedmatchmatch- A match returned bypartial.exec()
Returns:
truewhen the match read the end of the input: an atom ran out of input and took one of the|$(?![\s\S])branches described in How It Works; a greedy quantifier stopped at the end because there was nothing left to read; or$,\bor\Bheld there. More input could extend the match, change which alternative wins, or invalidate it, and its captures are the closest available rather than final — see Captures on a partial match.falsewhen every atom matched literally and nothing read past the last character consumed. No continuation of the input changes the match's index or text, and the captures are the ones the original pattern produces — except the two cases in What it cannot see below, where a read of the end leaves no marker andfalseis reported despite it.
import PartialMatchRegExp, { hitEnd } from "regex-partial-match";
const partial = new PartialMatchRegExp(/hello world/);
hitEnd(partial, partial.exec("hello")); // true — ran out of input inside "world"
hitEnd(partial, partial.exec("hello world!")); // false — settled short of the end
hitEnd(partial, partial.exec("hello world")); // false — consumed the last character without reading past it
const greedy = new PartialMatchRegExp(/hello \w+/);
hitEnd(greedy, greedy.exec("hello world")); // true — \w+ read the end looking for moreNote
Where the JDK is exact, hitEnd() is conservative in one place: a bounded greedy quantifier (?, {n,m}) fully taken at the end of the input reports true, although the engine attempted no further read there — on a group (/(ab)?/ on "ab" is true here and false in Java), and the same way for an unequal-bound {n,m} directly on a single atom once it's saturated at its maximum (/a{1,2}/ on "aa" is true, though no continuation can add a third a). Outside the two limits in What it cannot see, it is never wrong in the other direction.
See How It Works for why this can't be worked out from the match alone, and how hitEnd() records a read of the end.
Note
hitEnd() itself always requires ES2018+, regardless of the pattern: its truncation probe is built from named capturing groups internally, even for a pattern as plain as /^abc/. See Browser Compatibility — every other method holds to the ES2015 floor stated there.
- A read of the end inside a lookahead in an earlier iteration of a quantified group. The probe's markers are capturing groups, and
RepeatMatcherresets a quantified group's captures at the start of every iteration./(?:a(?=bcd)|b)+/on"abc"reads the end inside(?=bcd)in its first iteration, matchesbin its second, and reportsfalse— although"abcx"changes the match to"b"at index 1. Nothing placed inside the repeated atom survives the reset, so this is a limit of the marker approach rather than an oversight, and it is pinned by a test. - A read of the end inside a raw lookaround. Negative lookaheads and both lookbehinds are kept verbatim (see Caveats), so a read of the end inside them leaves no marker:
/^a(?!b)/on"a"reportsfalse, although"ab"invalidates the match. A scanner whose output must not depend on where its input was chunked should refuse or buffer patterns that use them, asreplace-content-transformerdoes.
Building the partial-match regex requires walking the entire source pattern once. As a side effect of that same walk, each instance records which syntactic constructs its pattern actually uses, exposed as a features set — no separate scan of the source is performed to produce it.
This is useful for consumers building on top of PartialMatchRegExp who need to reason about which constructs a specific pattern uses, without writing their own regex parser to find out. Two concrete cases:
- Flagging patterns likely to hit one of the caveats. For example, a pattern combining
backreferencewithlookbehind,negativeLookahead, ornegativeLookbehindis a candidate for the atomic-backreference caveat; one combiningbackreferencewithdisjunctionis a candidate for the prefix-ambiguous top-level alternation caveat. A consumer accepting user-supplied patterns can surface a warning instead of letting the edge case surprise someone later. - Restricting which constructs a product surface allows. e.g. a system that only wants to accept "simple" patterns (no lookaround, no backreferences) from untrusted input can check
featuresagainst an allow-list and reject the rest, without needing to hand-roll that check against the raw pattern source. - Deciding at construction time whether a pattern needs a careful path. A capture nested inside a lookaround is decided by the assertion rather than by the consumed text, so its value can vary with how far the input has been seen.
features.has("lookaroundCapture")isolates exactly those patterns, wherefeatures.has("lookahead") && features.has("capturingGroup")would also catch the ordinary/(\w+)(?= END)/.
import PartialMatchRegExp from "regex-partial-match";
const partial = new PartialMatchRegExp(/^[a-z]+(?<domain>\.[a-z]+)\1/);
partial.features; // Set { "startAnchor", "backreference", "namedGroup", "capturingGroup", "characterClass", "quantifier", "otherEscape" }
partial.features.has("backreference"); // trueRegexFeature is a string union, exported alongside PartialMatchRegExp:
| Feature | Matches | Notes |
|---|---|---|
patternCharacter |
An ordinary literal character | |
startAnchor |
Top-level ^ |
|
endAnchor |
Top-level $ |
|
wordBoundary |
Top-level \b |
|
nonWordBoundary |
Top-level \B |
|
lookahead |
(?=...) |
|
negativeLookahead |
(?!...) |
|
lookbehind |
(?<=...) |
|
negativeLookbehind |
(?<!...) |
|
backreference |
\1 |
|
namedBackreference |
\k<name> |
|
namedGroup |
(?<name>...) |
Always accompanied by capturingGroup — see below |
capturingGroup |
(...), including named groups |
|
lookaroundCapture |
A capturing group inside any lookaround | Nesting, not a construct — always accompanied by capturingGroup |
nonCapturingGroup |
(?:...) |
|
modifierGroup |
(?ims:...) |
|
modifierGroupWithRemoval |
(?ims-ims:...) |
Mutually exclusive with modifierGroup |
characterClass |
[...] |
|
nestedCharacterClass |
[...[...]...] |
v flag only |
classIntersection |
&& inside a character class |
v flag only |
classSubtraction |
-- inside a character class |
v flag only |
disjunction |
| |
|
quantifier |
*, +, ?, {n}, {n,}, {n,m} |
|
unicodePropertyEscape |
\p{...}, \P{...} |
u/v flag only — otherwise tagged otherEscape |
characterClassEscape |
\d, \D, \w, \W, \s, \S |
|
controlEscape |
\f, \n, \r, \t, \v |
|
controlLetterEscape |
\cX |
|
hexEscapeSequence |
\xXX |
|
unicodeEscapeSequence |
\uXXXX, \u{...} |
|
otherEscape |
Any other \X, e.g. \. |
\0 alone is tagged otherEscape only under u/v; otherwise backreference, like any other digit escape |
Three things worth knowing about how these tags line up with the grammar:
- One ECMA-262 production can map to several tags.
Assertionalone covers^,$,\b,\B, and all four lookarounds —featuressplits it by whichever discriminant is easiest to read off during the walk (^vs$,=vs!after(?<, etc.), since that information is free at the point each construct is recognised. - A named capturing group always carries both
namedGroupandcapturingGroup. The grammar treats a capturing group with a name and one without as the same production (( GroupSpecifier? Disjunction )), not two, so both tags are added together. lookaroundCapturerecords nesting, not a construct. Every other tag names something the source contains; this one names where something sits — a capturing group appearing lexically inside(?=...),(?!...),(?<=...)or(?<!...), at any depth of nested groups. It is the one relationship between constructs the flattened set cannot otherwise express:/(\w+)(?= END)/and/a(?=(?:b(?:x|(c))d|b))/both reportlookaheadandcapturingGroup, but only the second has a capture whose value the assertion decides.
ISC License - see LICENSE file for details.
Algorithm created by Lucas Trzesniewski.
Contributions are welcome! Please open an issue or pull request on GitHub.
| Project | Description |
|---|---|
incr-regex-package |
Incremental regex matcher |
dfa |
Compiles a regular expression like syntax to fast deterministic finite automata, which could be used to partial match? |
refa |
Can convert regular expressions to an Abstract Syntax Tree, which might afford partial-match capability? |
@eslint-community/regexpp |
A regular expression parser for ECMAScript with AST generation and visitor implementation |
Regex+ |
template literal, transforming native regular expressions |
Awesome Regex |
Curated list of tools, tutorials, libraries, and other resources, covering all major regex flavours |
replace-content-transformer |
A toolkit for stream content replacement, underpinned by regex-partial-match |
Footnotes
-
Mimicking JDK's
Matcher.hitEnd()↩ -
Testing the original, untransformed pattern looks like it should answer this — "did the input fully satisfy the original pattern?" — but it asks a different question: whether the original matches at all here, not whether this match reached the end of the input on its way. The two agree almost always, but a read of the end inside a zero-width assertion can make both return an identical result by different paths. See Why the question can't be answered from the outside for the case where they diverge. ↩