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

Source: https://voxgig.com/howto/recursive-descent-parser-in-typescript

- Audience: api-producer
- Level: beginner
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://nodejs.org/api/typescript.html)
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](https://chevrotain.io/docs/) | 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](https://craftinginterpreters.com/parsing-expressions.html) | 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](https://peggyjs.org/documentation.html) | 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.

```ts title="parser.ts"
// 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.

```ts title="lexer.ts"
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.

```ts title="parser.ts"
  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`.

```ts title="parser.ts"
  // 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:

```bash
node demo.ts
```

```text output
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.

```bash
node compare.ts
```

```text output
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](https://nodejs.org/api/test.html). The one that
earns its place parses every prefix of a valid filter, which is every way an input can end
mid-expression.

```ts title="filter.test.ts"
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](https://nodejs.org/api/vm.html), 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.

```bash
node --test filter.test.ts
```

```text output
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.

```ts title="optional-value.ts"
  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.

```bash
node pitfall.ts
```

```text output
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](https://github.com/Chevrotain/chevrotain/blob/v13.2.0/packages/chevrotain/src/parse/parser/traits/recognizer_engine.ts).
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](https://github.com/Chevrotain/chevrotain/blob/v13.2.0/packages/chevrotain/src/parse/parser/traits/recognizer_api.ts),
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

- [Remove left recursion from a PEG grammar](/howto/remove-left-recursion-from-a-peg-grammar)

- [Attach parse actions to grammar rules by name](/howto/attach-parse-actions-by-rule-name)

- [Recover from syntax errors and report several in one parse](/howto/report-multiple-syntax-errors-in-one-parse)

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

[^1]: [Crafting Interpreters](https://craftinginterpreters.com/parsing-expressions.html) 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](https://gcc.gnu.org/gcc-3.4/changes.html)
record that a hand-written recursive descent C++ parser replaced the YACC-derived parser of
previous releases, and the [release list](https://gcc.gnu.org/releases.html) dates 3.4.0 to
April 18, 2004.

[^2]: The [chevrotain FAQ](https://chevrotain.io/docs/FAQ.html) 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.