Your API takes a filter in the query string, such as
status = "active" and (age >= 18 or role in (admin, editor)). A regular expression cannot
match the parentheses, and splitting on and breaks on the first value that contains the word.
A caller who forgets a bracket gets a 500, or a filter that matches every row.
What you get
You will end up with a parser that turns a filter into a tree and names the line and column of every syntax error. Hostile input cannot hang it or overflow the stack. This is for you if your API accepts a small query language.
Short answer
A recursive descent parser in TypeScript is a tokenizer that records where each token starts,
plus one method per grammar rule that moves through the tokens only with peek, accept and
expect. Make expect throw an error naming the line, the column, what was wanted, and what was
found. Cap the nesting depth, and check that every loop iteration consumed a token, because a
loop whose body can match nothing never ends.
You will need
Node 22.18 or later, which runs a .ts file directly
by stripping the types, and the grammar written down. Verified 2026-10-15 against Node 22.22.0,
peggy 5.1.0 and chevrotain 13.2.0. The parser has no dependencies; only the comparison installs
the other two. Whether to hand-write a parser at all is the subject of the page on choosing a
parsing approach for your format.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| chevrotain | The grammar should be TypeScript you can step through in a debugger, with no generated file | Token objects, numbered calls such as SUBRULE2, and messages that show users your token names | Users read the errors, or the grammar’s readers think in grammar notation rather than an API |
| hand-written recursive descent | A small language whose errors users read, with no build step and no dependency | More than four times the lines of a grammar file, and every grammar change is a code change | The grammar runs past a page, or several people change it |
| peggy | The grammar should read like a specification, and a generate step is acceptable | Messages built from character classes and rule names, and no limit on nesting depth | Untrusted input reaches it, or users need messages written for people |
Hand-written recursive descent is also what several production compilers use.1 Its cost is lines. The parser below is more than four times the size of the peggy grammar, and a reviewer reads a diff of code rather than a diff of a grammar. peggy is the shortest, but its messages list alternatives rather than explain them. chevrotain needs no generate step, at the price of an API to learn and messages written for the grammar’s author.
Write the grammar down first
The grammar lives as a comment at the top of the parser, one line per rule. It is the only specification the code has, so keep it next to the code it describes.
// A recursive descent parser for the filter language: one method per grammar
// rule, and peek, accept and expect as the only ways to move through tokens.
//
// filter = orExpr EOF
// orExpr = andExpr ("or" andExpr)*
// andExpr = primary ("and" primary)*
// primary = "(" orExpr ")" / comparison
// comparison = FIELD OP value / FIELD "in" list
// list = "(" value ("," value)* ","? ")"
// value = STRING / NUMBER / "true" / "false" / FIELD
Precedence comes from the order of the rules. orExpr calls andExpr, so and groups first and
a = 1 or b = 2 and c = 3 reads as a = 1 or (b = 2 and c = 3), with no precedence table.
Tokenize, and keep the offsets
The tokenizer turns text into a flat list of tokens, each with the offset it started at. The parser then never looks at characters, and every error can be reported against a position.
export function tokenize(src: string): Token[] {
const tokens: Token[] = []
let pos = 0
while (pos < src.length) {
const ch = src[pos]
const space = match(SPACE, src, pos)
if (space) {
pos += space.length
continue
}
if (ch === '(' || ch === ')' || ch === ',') {
tokens.push({ type: ch, text: ch, offset: pos })
pos++
continue
}
const op = OPS.find((o) => src.startsWith(o, pos))
if (op) {
tokens.push({ type: 'op', text: op, offset: pos })
pos += op.length
continue
}
OPS lists <= before <, so the longer operator wins. Keywords are matched as words and then
looked up, which is why a field called orange is a field and not or followed by ange. The
list always ends with an eof token at the end offset, so the parser can report a missing token
at the place it was due.
Move through tokens with three helpers
Every rule moves through the input with the same three calls. peek looks, accept consumes a
token of a given type or returns undefined, and expect consumes it or throws.
peek(): Token {
return this.tokens[this.pos]
}
accept(type: TokenType): Token | undefined {
const token = this.peek()
if (token.type !== type) return undefined
if (type !== 'eof') this.pos++
return token
}
expect(type: TokenType, wanted: string): Token {
return this.accept(type) ?? this.fail(`expected ${wanted}`)
}
fail(message: string): never {
const token = this.peek()
const found = token.type === 'eof' ? 'the end of the filter' : `'${clip(token.text)}'`
throw new ParseError(this.src, token.offset, `${message}, found ${found}`)
}
The error message is written at the call site, by the rule that knows what it wanted. That is
the main case for writing the parser by hand. clip cuts the quoted input to 24 characters, so a
100 KB query string does not come back inside its own error message.
Write one method per rule
Each rule in the comment becomes a method of the same name. A * in the grammar is a while
loop, and a / is an if.
// orExpr = andExpr ("or" andExpr)*
orExpr(): Filter {
const args = [this.andExpr()]
while (this.accept('or')) args.push(this.andExpr())
return args.length === 1 ? args[0] : { op: 'or', args }
}
// andExpr = primary ("and" primary)*
andExpr(): Filter {
const args = [this.primary()]
while (this.accept('and')) args.push(this.primary())
return args.length === 1 ? args[0] : { op: 'and', args }
}
// primary = "(" orExpr ")" / comparison
primary(): Filter {
const open = this.accept('(')
if (!open) return this.comparison()
this.nest(open)
const inner = this.orExpr()
const { line, column } = lineColumn(this.src, open.offset)
this.expect(')', `')' to close the '(' at ${line}:${column}`)
this.depth--
return inner
}
Two choices in that code protect whatever reads the tree. A chain of and becomes one node with
many arguments rather than a node per and, so 10,000 conditions are one level deep.
nest counts parentheses and throws past 32, because each level is a stack frame here and again
in any evaluator that walks the result.
Run the parser over two valid filters and six broken ones:
node demo.ts
status = "active" and (age >= 18 or role in (admin, editor))
(and (= status "active") (or (>= age 18) (in role ["admin" "editor"])))
a = 1 or b = 2 and c = 3
(or (= a 1) (and (= b 2) (= c 3)))
status =
ParseError: 1:10: expected a value after '=', found the end of the filter
status == "active"
ParseError: 1:9: expected a value after '=', found '='
status = "active" and (age >= 18 or role = admin
ParseError: 1:49: expected ')' to close the '(' at 1:23, found the end of the filter
age >= 18 role = admin
ParseError: 1:11: expected 'and', 'or', or the end of the filter, found 'role'
role in (admin editor)
ParseError: 1:16: expected ',' or ')' after a value, found 'editor'
name = "Ada
ParseError: 1:8: string is not closed before the end of the line
Measure it against peggy and chevrotain
The same grammar is in filter.peggy and chevrotain-filter.ts, and both build the same tree.
The script counts each one’s lines, prints what each says about broken input, and diffs each
against a copy in with-not/ that adds a not operator.
node compare.ts
lines of code, comments and blank lines excluded
hand-written 207 lexer.ts + parser.ts
peggy 46 filter.peggy + peggy-filter.ts
chevrotain 106 chevrotain-filter.ts
same tree from all three on 6 of 6 valid filters
status =
hand-written 1:10 ParseError: expected a value after '=', found the end of the filter
peggy 1:10 SyntaxError: Expected "false", "true", field name, number, or string but end of input found.
chevrotain none NoViableAltException: Expecting: one of these possible Token sequences: 1. [Str] 2. [Num] 3. [True] 4. [False] 5. [Field] but found: ''
status == "active"
hand-written 1:9 ParseError: expected a value after '=', found '='
peggy 1:9 SyntaxError: Expected "false", "true", field name, number, or string but "=" found.
chevrotain 1:9 NoViableAltException: Expecting: one of these possible Token sequences: 1. [Str] 2. [Num] 3. [True] 4. [False] 5. [Field] but found: '='
age >= 18 role = admin
hand-written 1:11 ParseError: expected 'and', 'or', or the end of the filter, found 'role'
peggy 1:11 SyntaxError: Expected "and", "or", or end of input but "r" found.
chevrotain 1:11 NotAllInputParsedException: Redundant input, expecting EOF but found: role
role in (admin,
hand-written 1:16 ParseError: expected a value or ')', found the end of the filter
peggy 1:16 SyntaxError: Expected ")", "false", "true", field name, number, or string but end of input found.
chevrotain none MismatchedTokenException: Expecting token of type --> RParen <-- but found --> '' <--
name = "Ada
hand-written 1:8 ParseError: string is not closed before the end of the line
peggy 1:8 SyntaxError: Expected "false", "true", field name, number, or string but "\"" found.
chevrotain 1:8 LexingError: unexpected character: ->"<- at offset: 7, skipped 1 characters.
50000 nested parentheses
hand-written 1:33 ParseError: filter nests deeper than 32 levels
peggy none RangeError: Maximum call stack size exceeded
chevrotain none RangeError: Maximum call stack size exceeded
adding a `not` operator: lines changed, from the files in with-not/
hand-written +18 -6 in lexer.ts, parser.ts
peggy +6 -2 in filter.peggy
chevrotain +16 -3 in chevrotain-filter.ts
same tree from all three with-not parsers on 3 of 3 filters
peggy reads characters, not tokens, so it reports "r" found where the other two name the word
role. The chevrotain end-of-input token carries no line, so two of its errors have no position
unless you compute one from the token before. Its messages name Str and RParen, the
identifiers in your grammar code.2
The deep nesting row is the one to act on if untrusted input reaches the parser. The peggy and
chevrotain parsers both overflow the stack and throw a RangeError, which an error handler that
maps only parse errors to a 400 answers with a 500.
The change row is the trade-off in numbers. Teaching peggy not is eight lines of grammar a
reviewer can read as grammar. The hand-written change is 24 lines of code across two files.
Check it worked
Nine tests run with the Node test runner. The one that earns its place parses every prefix of a valid filter, which is every way an input can end mid-expression.
test('every prefix of a valid filter parses or fails inside itself, and none loops', () => {
const full = 'status = "active" and (age >= 18 or role in (admin, editor,))'
const prefixes = Array.from({ length: full.length + 1 }, (_, i) => full.slice(0, i))
const results = parseBounded('strict value', prefixes, 10_000)
assert.equal(results.length, prefixes.length)
for (const [i, r] of results.entries()) {
if ('tree' in r) continue
if ('timedOut' in r) assert.fail(`prefix ${JSON.stringify(prefixes[i])} sent the parser into a loop`)
assert.equal(r.error, 'ParseError', `prefix ${JSON.stringify(prefixes[i])}: ${r.message}`)
assert.ok(r.offset! <= i, `prefix ${JSON.stringify(prefixes[i])} reported offset ${r.offset}`)
}
})
parseBounded runs each parse under a vm timeout, so a parser
that loops fails the test instead of hanging the run. The clock starts when the parse does, so a
slow CI machine starting Node cannot turn a parse that finishes into a reported hang. Every prefix
must either parse or throw a ParseError whose position lies inside the prefix, never a
TypeError from reading past the last token.
node --test filter.test.ts
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
When it goes wrong
The parser hangs on role in (admin, after a small edit. Somebody wanted in (a, b,) to parse,
so they made the value inside the list optional. At the end of the input the loop body consumes
nothing, the while condition is still true, and the loop never ends.
list(): Value[] {
this.expect('(', "'(' after 'in'")
const values: Value[] = []
while (this.peek().type !== ')') {
const value = this.optionalValue()
if (value !== undefined) values.push(value)
this.accept(',')
}
this.expect(')', "',' or ')' after a value")
return values
}
The fix is a progress check in every loop: note the position at the top of the iteration, and throw if it has not moved by the bottom. The guarded variant in the same file adds those two lines.
node pitfall.ts
role in (admin,
strict value 1:16: expected a value or ')', found the end of the filter
optional value stopped after 2 s, still looping
optional value, guarded 1:16: expected a value, ',' or ')', found the end of the filter
role in (admin editor)
strict value 1:16: expected ',' or ')' after a value, found 'editor'
optional value accepted (in role ["admin" "editor"])
optional value, guarded accepted (in role ["admin" "editor"])
peggy, optional value: GrammarError: Possible infinite loop when parsing (repetition used with an expression that may not consume any input)
The guard turns the hang into an error at the right column. It does not fix the grammar, though:
with the value optional, (admin editor) passes with no comma. parser.ts keeps the value
required and leaves the loop when no comma follows, which handles the trailing comma without the
hole.
peggy refuses the same edit when it generates the parser. In chevrotain, a repetition compares the token position before and after each iteration and stops when it has not moved, in the 13.2.0 source. By hand, that check is yours to write.
The second failure is a chevrotain rule called or, which breaks the parser while it records the
grammar. The parser class already has a lowercase
or method,
and RULE assigns each rule to a property of the same name, so the rule replaces it. Call it
orExpr.
When not to do this
Do not hand-write a parser for a grammar that changes every sprint. Every change is a code change
with no grammar file to diff it against. The rule comment at the top of parser.ts stays correct
only as long as somebody remembers to edit it.
Do not hand-write one for a language somebody else specified. SQL, JSONPath and CSS selectors have grammars, and a generator fed the published grammar tracks it more closely than a reading of it does.
Do not put peggy or chevrotain in front of untrusted input without a depth limit. Both overflow
the stack on deep nesting, so count parentheses before parsing or catch the RangeError and
answer 400.
Do not skip the prefix test. A recursive descent parser that has never been fed a truncated input has not been tested on the input it will see most.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, peggy 5.1.0 and chevrotain 13.2.0. Every output block is what the command preceding it printed, run in the sample directory.
Footnotes
-
Crafting Interpreters names GCC, V8 and Roslyn among the production implementations that use recursive descent. GCC arrived there from the other direction. The release notes for GCC 3.4 record that a hand-written recursive descent C++ parser replaced the YACC-derived parser of previous releases, and the release list dates 3.4.0 to April 18, 2004. ↩︎ Back to text
-
The chevrotain FAQ explains the numbered calls. Each combination of method, suffix and argument maps to a location in the grammar, and that location drives the lookahead, the error messages and the recovery. The same FAQ keeps two questions about grammars that broke after minification or bundling. Each answer says that the dependence behind the problem was removed in version 6.0.0. The questions have outlived the problem they describe. ↩︎ Back to text