JAVASCRIPT / REGULAR EXPRESSIONS
Character classes and negated sets
Build character sets and negated sets in JavaScript regexes, choose ranges correctly, and know which characters stay special inside brackets.
What you will learn
- Read [abc] as one character chosen from a set, and [a-z] as a code point range
- Inside brackets only \ ] ^ (first) and - (between chars) stay special
- Use [^...] knowing it consumes a character instead of asserting absence
- Combine shorthands: [\d\s] for either, [^\d\s] for neither, [\s\S] for any
Understanding Character classes and negated sets
A bracket expression is alternation compressed down to a single character. [aeiou] is one atom: the engine looks at the character sitting at the current position, asks whether it is a member of the set, and on success advances exactly one character. Because membership is the only question being asked, order and duplicates are irrelevant, so [abc], [cba] and [aabbc] behave identically. A range like [a-z] is shorthand for every code point from 97 to 122 inclusive, which is why a range only means what you expect when you know where its endpoints sit in the code point table.
Inside the brackets the grammar changes, because at the set level there is nothing to repeat, group or alternate. The characters . * + ? ( ) | $ lose their powers and stand for themselves, so [.*] matches a literal dot or a literal asterisk. Only four things still carry meaning: \ starts an escape, ] closes the set, ^ negates it when it is the very first character, and - forms a range when it sits between two characters. That last rule is positional, which is why [-.] and [.-] are both a two-member set while [.-a] is a range from 46 to 97.
Negation inverts the membership test and nothing else. [^,] still consumes one character, it just demands that the character be outside the listed set, which is why [^,]* is the usual way to scan one CSV field, and why /a[^b]/ cannot match the one-character string 'a' — there is no character left to test. The same reasoning explains two facts that look unrelated: [^\d] matches a newline even though . does not, because line terminators are kept out of . by a rule of its own rather than by any set, and [\s\S] matches absolutely every character because it unions a set with its own complement.
const log = "id:42 id:7 code:A9";
// A set is one alternative per character: every match is a single digit
console.log(log.match(/[0-9]/g));
// The set is the atom, so the quantifier belongs outside the bracket
console.log(log.match(/[0-9]+/g));
// Inside a set, . and * are ordinary characters
console.log("a.b*c".match(/[.*]/g));
// [^...] means: one character, but not one of these
console.log(log.match(/[^a-z0-9 :]/g));
// A negated set still has to consume a character
console.log(/[^x]/.test(""));A bracket set is a single-character atom whose membership test can be inverted, so [^...] never asserts absence — it consumes one character and requires it to be outside the set.
Worked examples
Ranges are code point spans
Shows why [A-z] is not a way to say 'any letter'.
console.log("Snake_Case".match(/[A-z]+/g));
console.log("Snake_Case".match(/[A-Za-z]+/g));
console.log("Z".charCodeAt(0), "_".charCodeAt(0), "a".charCodeAt(0));Example explained
Line 1[A-z] covers 65 through 122 in one unbroken span, so the underscore never breaks the run and the whole string comes back as one match.
Line 2[A-Za-z] is two separate spans with the gap 91-96 left out, so the match stops at the underscore and returns two words.
Line 3The code points printed on the last line are the reason: _ is 95, which lands between Z at 90 and a at 97.
Shorthands, unions, and the newline
Demonstrates that a negated set excludes only what you list, unlike the dot.
const text = "line 1\nline 2";
console.log(/./.test("\n"));
console.log(/[^x]/.test("\n"));
console.log(text.match(/[\s\S]+/)[0].length);
console.log("a1 b2".match(/[\d\s]/g));
console.log("a1 b2".match(/[^\d\s]/g));Example explained
Line 1The dot excludes line terminators by definition, so it fails on a lone newline; [^x] excludes only x, so the newline passes.
Line 2[\s\S] is a set unioned with its own complement, so it matches every character and the greedy match spans all 13 characters including the newline.
Line 3Listing two shorthands in one set means 'either', so [\d\s] picks up the digits and the space.
Line 4Negating that same list means 'neither', which leaves exactly the letters.
Placing - and ^, and an invalid range
Shows the positional rules for the two characters that change meaning inside a set.
console.log("2024-05-06".match(/[-0-9]+/g));
console.log("2024-05-06".match(/[0-9-]+/g));
console.log("a^b".match(/[b^]/g));
try {
new RegExp("[9-0]");
} catch (e) {
console.log(e.constructor.name);
}Example explained
Line 1A hyphen first in the set has no left operand, so it is a plain member and the 0-9 range that follows is still a range.
Line 2A hyphen last has no right operand, so it is a plain member too; both patterns describe the same set of eleven characters.
Line 3The caret only negates in first position, so [b^] is a two-member set and the matches come back in string order.
Line 4[9-0] asks for a range whose start is above its end, which the engine rejects at compile time rather than treating as empty.
Important notes
The i flag is applied to the membership test before negation, so /[^a-z]/i does not match 'A': with i in effect, A already counts as a member of a-z and is therefore excluded.
\d is exactly [0-9] and \w is exactly [A-Za-z0-9_], both ASCII only, so a name like Zoë needs explicit ranges or Unicode property escapes with the u flag.
Common mistakes
Using [A-z] to mean 'any letter'. It spans 65 to 122, so it also accepts [ \ ] ^ _ and the backtick, and a validator built on it happily passes user_name and a\b.
Putting the quantifier inside the brackets, as in [0-9+]. That is an eleven-member set (ten digits plus a plus sign), so the string +++ passes a check that was meant to require digits.
Writing /a[^b]/ to mean 'a not followed by b'. The negated set consumes the next character, so the pattern fails at the end of the string and eats a character the rest of the pattern may have needed; a zero-width lookahead is the right tool there.
Try it yourself
Change, predict, then run
In a browser console set s = 'co-op, e-mail: a_b@x.io' and write one regex with the g flag whose set of letters, digits, hyphens and underscores returns the five word-like chunks. Then negate the same set and confirm you get the punctuation runs between them instead.
Open the JavaScript workspaceCheck your understanding
You want to match 'a' only when it is not followed by 'b'. Why does /a[^b]/ fail on the string 'a'?
- [^b] still has to consume one character, and there is no character after the a
- [^b] is an assertion, so it is only valid at the end of a pattern
- The regex needs the g flag before it will look past the first character
- [^b] matches the empty string, so the engine loops forever and gives up
Show answer
Negation flips the membership test but the set is still a single-character atom, so it needs a character at position 1 to test, and 'a' has none. The last option is tempting because 'not b' feels true of an empty position, but a character set never matches zero characters — a zero-width 'not followed by' check requires a lookahead instead.