How-to › Parse, validate and transform data

How to write a recursive descent parser by hand in TypeScript#

Parse a filter language with a tokenizer, a method per grammar rule, and peek and expect helpers, then measure it against the same grammar in peggy and chevrotain.

Audience
API producer
Level
beginner
Topic
Write a grammar and parser
Languages
TypeScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
chevrotainThe grammar should be TypeScript you can step through in a debugger, with no generated fileToken objects, numbered calls such as SUBRULE2, and messages that show users your token namesUsers read the errors, or the grammar’s readers think in grammar notation rather than an API
hand-written recursive descentA small language whose errors users read, with no build step and no dependencyMore than four times the lines of a grammar file, and every grammar change is a code changeThe grammar runs past a page, or several people change it
peggyThe grammar should read like a specification, and a generate step is acceptableMessages built from character classes and rule names, and no limit on nesting depthUntrusted 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.

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

  1. 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

  2. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.