A list of strings that a pattern must accept cannot define the pattern, because the loosest possible expression, one that matches everything, accepts all of them. What narrows a pattern down is the list of strings it has to turn away: the value with one digit too many, the value with text around it, the value in another alphabet. This guide shows how to build a JavaScript regular expression from examples with that in mind. It covers choosing the rejected strings, anchors and the m flag, the word-character trap that breaks patterns on Bulgarian or French text, the u and v flags, and a test harness that was checked against deliberately wrong patterns. Catastrophic backtracking is a different failure, covered in our guide to a regex that locks a server, and is not repeated here.

Why the rejected strings do the work

Take a request for a four-digit code and two examples that must match, 1000 and 9999. The pattern /.*/ passes both. So does /\d/, and so does /\d{4}/, which looks right. Only when someone adds rejected strings does the real shape appear: 10000 must fail, a1000 must fail, 999 must fail, and the empty string must fail. Each rejected string removes one family of wrong patterns, and the pattern that survives all of them is the one worth keeping.

So when you collect examples, spend your effort on the negative list. Good candidates come from a short routine, applied to every positive example:

  • One piece too short and one too long. For a four-digit code, three digits and five digits.
  • Valid text with something attached. A leading letter, a trailing letter, a trailing space, text on both sides.
  • The right characters in the wrong order or with the wrong separator. A date with slashes where dashes are required.
  • Empty, and whitespace only.
  • A trailing newline, and a second line. Values come from files, textareas and clipboards.
  • The same idea in another script. A Latin look-alike of a Cyrillic letter, or an accented letter where a plain one is expected.

A near-miss is worth more than a random bad string. abcd is rejected by almost any digit pattern, so it teaches little, while a1000 separates an anchored pattern from an unanchored one.

Anchors decide whether the whole value or just a piece must fit

Without anchors a regular expression is a search: it succeeds if the pattern appears anywhere. MDN presents ^ and $ as assertions that fire at the beginning and at the close of the input, and adds that, without the m flag, text may not sit beyond them if the pattern is to match at all (MDN, input boundary assertions). A validator that tests one whole value therefore needs both: ^\d{4}$, not \d{4}. In a run on Node v24.16.0, the unanchored version accepted the text abc 12345 xyz, and the anchored one rejected it.

The m flag changes the meaning. MDN states that once m is set, every line gets its own beginning and close, and both assertions fire there (MDN, input boundary assertions). That is the right choice for finding a pattern at line starts in a multi-line log, and the wrong one for validating a single value: with m, the text 1234 followed by a newline and the word foo passed ^\d{4}$ in our run, and without it the same text was rejected. If the input can contain line breaks and the whole value must still fit, leave m off. If the dot is meant to cross lines, that is the s flag, and it is separate from m.

One more observation from the same Node run: ^\d{4}$ rejected the text 1234 with a newline at the end. Some other languages let $ match before a final newline, so a pattern ported from one of them can behave differently here. A trailing newline belongs in the negative list so that nobody has to remember this.

\b and \w only know ASCII

This is the trap that hides longest, because patterns that use \b work on English test data. MDN lists the word characters as the unaccented Latin letters of both cases, the digits zero to nine and the underscore, and says \w stands for the same set (MDN, word boundary assertion, MDN, character class escapes). The ECMAScript specification defines the set through an operation called WordCharacters, which starts from the ASCII word characters and adds extra characters only when a Unicode flag and the i flag are both on, and only those whose case-folded form is an ASCII word character (ECMAScript specification). The u flag alone does not make \w understand letters of other alphabets.

The effects are easy to reproduce. In our run, /\bдума\b/u did not match in the sentence една дума тук: the Cyrillic letters are not word characters, so there is no boundary at either end. And /\w+/u applied to café returned only caf, because the accented letter ends the run.

The fix is to say what you mean, with a Unicode property. A property escape such as \p{L} stands for any letter, and MDN notes that these escapes only work in Unicode-aware mode: without the u or v flag they are plain identity escapes for the letter p (MDN, character class escapes). A whole-word search for any script is then a pair of lookarounds:

const word = /(?<!\p{L})дума(?!\p{L})/u;
word.test('една дума тук'); // true
word.test('думата');         // false, the word continues

Both lines gave the shown answers in our run, and the second one is the negative example that matters: a boundary test that cannot reject думата has not been tested. If digits and apostrophes can be part of a word in your data, widen the lookarounds to [\p{L}\p{N}'].

What the u and v flags change

The u flag turns on Unicode mode, and several things change with it. The dot matches a whole code point instead of half of a surrogate pair: in our run /^.$/ rejected a single emoji while /^.$/u accepted it. Property escapes become available. And the syntax gets stricter: in u mode a pattern such as [\w-.], a range whose end is a class, was a syntax error with the message Invalid character class, and an escape such as \- outside a class was an Invalid escape error. Without the flag, the same [\w-.] was accepted silently and matched both a dot and a hyphen. Early errors are the better behaviour, since a pattern that compiles with a hidden misreading never gets noticed.

A letter is not always one code point. In our run \p{L} rejected the two-code-point sequence of e plus a combining accent, so a name pattern that must accept decomposed text needs \p{L}\p{M}*. Whether to accept such input is a product decision, and it belongs in the example lists.

The v flag is described by MDN as an upgrade to the u mode (MDN, unicodeSets). Among other things, it allows set operations inside a class: /[\p{L}--[a-z]]/v matched é and rejected e in our run, which is a clean way to write "a letter, but not a plain Latin one". It keeps the strict errors of u: the [w-.] class that u refused was refused under v too. Its class syntax differs from u in other ways, so retest a pattern after switching between them. Use v only where you know the runtimes support it, and never together with u, since the two flags cannot be combined.

Character class habits that go wrong

MDN explains that a hyphen standing between two characters inside square brackets forms a range, while one placed first or last is read as a plain hyphen (MDN guide, character classes). Where you mean a plain hyphen, put it at an edge of the class or escape it. A few habits follow:

  • [a-z] is Latin only. It rejects é and every Cyrillic letter, so a negative example in another alphabet should be a positive one if you intended to accept it.
  • A negated class matches line breaks. [^"]* runs across lines unless a line break is excluded in the class. The dot behaves the other way round: it skips line breaks unless the s flag is on. Our run confirmed both.
  • \s and \d are not interchangeable with your intent. Say what you accept in a class you can read, and add a negative example for the case that tempts you to widen it.

A harness that can fail

Keep the examples as two arrays in the file next to the pattern, and test every string, in both lists, with one function:

function check(re, pos, neg) {
  const bad = [];
  for (const s of pos) if (!re.test(s)) bad.push('MISS ' + JSON.stringify(s));
  for (const s of neg) if (re.test(s)) bad.push('FALSE ' + JSON.stringify(s));
  return bad;
}
const pos = ['1000', '9999'];
const neg = ['999', '10000', 'abcd', 'a1000', '1000\n'];
check(/^\d{4}$/, pos, neg); // []

An empty list of failures means little by itself, so make the harness prove it can report one. We ran the same lists against two wrong patterns. The unanchored /\d{4}/ came back with two entries: 10000 and a1000 were accepted. The half-anchored /\d{4}$/ came back with the same two. Then we changed the anchored pattern by adding m and extended the negative list with a two-line value, 1000 followed by a line break and an x; the harness reported that string as accepted. Every one of those runs reported something, which shows the harness reacts to a wrong pattern, and the m run shows why the near-miss strings are worth adding.

Two pitfalls belong to the harness itself. A pattern with the g flag keeps a position between calls to test: in our run the same global pattern returned true and then false for the same input, so build test patterns without g or reset the position first. And testing only strings you wrote yourself hides your own blind spots, so when a bug report arrives, add the offending string to the right list before changing the pattern, so that the list grows from real failures.

Last, run the harness against the exact object you ship. Copying the pattern into the test file by hand lets the two versions drift apart, and the test then guards a pattern that does not exist in production.

When this does not apply

Scope: JavaScript patterns only. Other engines treat the dot, the closing anchor and the word boundary differently, and in which flags exist, so a pattern should be retested after moving. The observations about $, \w, the u flag, \p{L} and v came from short runs on one Node release, and each is also backed by the cited documentation, but a browser or an older runtime may differ, especially for the v flag. Some formats cannot be settled by any pattern: a full email address grammar, nested structures, or a date that must be a real calendar day. For those, use a pattern as a first filter and a parser or a library for the decision. A pattern is also not a safe place to run on hostile text unless you have looked at its backtracking cost. No performance was measured here, and the example lists above are illustrations, not a complete test set for any real field.

A skill for this

Our regex-from-examples skill takes a description plus the lists of strings to accept and to reject, and answers with a JavaScript pattern and its flags as JSON. It anchors validators so that surrounding text is refused, adds the s or m flag only when the input spans lines, writes word boundaries for non-English text with property escapes and the u flag, and keeps clear of nested quantifiers that can hang on a near-miss. It treats the rejection list as binding: a pattern that accepts a listed bad string is not a valid answer.

Read this post as Markdown: /blog/regex-from-examples-must-not-match.md · Atom feed.