# How to add a literal to a JSON grammar without forking the parser

> Add a date literal to a JSON parser with a tabnas lexer plugin instead of a copied grammar, and compare it with a peggy fork and a chevrotain subclass.

Source: https://voxgig.com/howto/extend-a-json-grammar-with-a-new-literal

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Add the literal to `@tabnas/json` with a lexer plugin on a derived instance, so the grammar's rules stay as upstream wrote them. A `match.value` pattern for four digits, two, and two turns `2026-09-02` into a value token, which the `val` rule already accepts. Keep the pattern tight enough to leave `2026` a number, and run an accept and reject corpus against the stock and extended parsers.

---
## You will need

Node 22 or later. Verified 2026-10-21 against Node 22.22.0, `@tabnas/json` 0.5.18,
`@tabnas/parser` 0.12.14, peggy 5.1.1, and chevrotain 13.2.0. The sample adds one literal, a
calendar date such as `2026-09-02`, three ways, and runs the same eight documents through each. It
does not cover times or time zones. Binding behavior to rule names is covered in
[attach parse actions to grammar rules by name](/howto/attach-parse-actions-by-rule-name).

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [chevrotain OVERRIDE_RULE](https://chevrotain.io/docs/features/grammar_inheritance.html) | The parser is a chevrotain class written for subclasses to extend | Restating every alternative of the overridden rule, and a base class that must defer its self-analysis | The upstream parser analyzes itself in its constructor, as the chevrotain JSON example does |
| [peggy, a copied grammar](https://peggyjs.org/documentation.html) | You expect the dialect to drift further from JSON and want full control | A 150-line copy that you now maintain, which receives no upstream fix unless you merge it by hand | The change is one literal and upstream is still moving |
| [@tabnas/json plugin](https://github.com/tabnas/json) | The literal is a scalar the lexer can recognize by its pattern | A pattern you must keep from overlapping numbers, and errors that say `unexpected` instead of your converter's message | The literal has structure of its own, which needs tokens and alternates |

Voxgig maintains tabnas. This page compares it with peggy and chevrotain.

A fork is immune to upstream changes, fixes included, because it no longer follows upstream.
The chevrotain override keeps the base class but restates the whole rule, so an alternative added
upstream never reaches your subclass. The tabnas plugin names no rule. It relies on the `val` rule
accepting value tokens and on the order in which the lexer tries its patterns, which costs less to
keep but still needs the corpus.

## Teach the lexer the literal, not the rules

`@tabnas/json` is a plugin for the tabnas engine, and its `val` rule already opens on any value
token. A `match.value` entry in the engine's options turns text that matches a pattern into a value
token, so a plugin that registers one adds the literal without naming a rule. The
[options reference](https://github.com/tabnas/parser/blob/main/ts/doc/options.md#match) uses a date
for its own example. The pattern here is the `full-date` of
[RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html#section-5.6), four digits, two, and two.

```js title="tabnas-date.mjs"
// Four digits, two, and two. Anchored, as the engine requires, and tight
// enough to leave a bare year such as 2026 to the number matcher.
export const DATE = /^\d{4}-\d{2}-\d{2}/

// The plugin. match.value runs before the number matcher and emits a value
// token, and the stock val rule already takes any value token, so no rule is
// named or changed. Return nothing: use() hands back what a plugin returns.
export function dateLiteral(tn) {
  tn.options({
    match: {
      value: {
        date: { match: DATE, val: (m) => toDate(m[0]) },
      },
    },
  })
}

// make() derives a fresh instance each time, so the stock parser is untouched.
export const stock = make()
export const extended = make().use(dateLiteral)
```

```bash
node compare.mjs tabnas
```

```text output
engines: @tabnas/json 0.5.18

document                    stock                 extended
{"due": 2026-09-02}         rejected              {"due":date(2026-09-02)}
[2026-09-02, 2026]          rejected              [date(2026-09-02),2026]
{"leap": 2024-02-29}        rejected              {"leap":date(2024-02-29)}
{"year": 2026}              {"year":2026}         {"year":2026}
{"id": "2026-09-02"}        {"id":"2026-09-02"}   {"id":"2026-09-02"}
{"due": 2026-9-2}           rejected              rejected
{"due": 2026-02-30}         rejected              rejected
{"due": 2026-09-02T10:00}   rejected              rejected

16 of 16 results as the corpus expects
```

The stock parser refuses every date, and the extended one builds a `Date` for each, in an object
and as the first element of an array. `2026` stays a number and the quoted `"2026-09-02"` stays a
string. Both refuse the last three documents: a month and day without their leading zeros, the
thirtieth of February, and a date with a time after it. `toDate` in `date.mjs` refuses the February date,
because `Date` itself turns `2026-02-30` into the second of March.[^1]

## Fork the grammar in peggy

peggy compiles a grammar file into a parser, so a new literal means a new rule in the grammar, and
the grammar is upstream's. Copy [peggy's JSON example](https://github.com/peggyjs/peggy/blob/main/examples/json.pegjs)
and add a `date` rule to the `value` choice.[^2]

```text title="json-date.pegjs"
value
  = false
  / null
  / true
  / object
  / array
  / date   // FORK: before number, which would take the year and stop
  / number
  / string

false = "false" { return false; }
null  = "null"  { return null;  }
true  = "true"  { return true;  }

// FORK: a date literal such as 2026-09-02, built by the caller's toDate.
date "date"
  = DIGIT DIGIT DIGIT DIGIT "-" DIGIT DIGIT "-" DIGIT DIGIT {
      try {
        return options.toDate(text());
      } catch (err) {
        error(err.message);
      }
    }
```

```bash
diff json.pegjs json-date.pegjs
```

```text output
37a38
>   / date   // FORK: before number, which would take the year and stop
44a46,55
> // FORK: a date literal such as 2026-09-02, built by the caller's toDate.
> date "date"
>   = DIGIT DIGIT DIGIT DIGIT "-" DIGIT DIGIT "-" DIGIT DIGIT {
>       try {
>         return options.toDate(text());
>       } catch (err) {
>         error(err.message);
>       }
>     }
> 
56c67,68
<           result[element.name] = element.value;
---
>           // FORK: define the key, as JSON.parse does. Assigning to __proto__ sets the prototype.
>           Object.defineProperty(result, element.name, { value: element.value, enumerable: true, writable: true, configurable: true });
```

The fork adds 13 lines, rewrites 1, and carries the other 149 with it. The order inside `value` matters,
because a peggy choice
[returns the first alternative that matches](https://peggyjs.org/documentation.html#grammar-syntax-and-semantics-parsing-expression-types)
and `number` matches `2026` before `date` is tried. The action calls peggy's `error()`, which
reports the converter's message at the literal's line and column.

The rewritten line fixes a bug that came with the copy. Upstream's object action assigns each member
with `result[name] = value`, and an assignment to `__proto__` sets the object's prototype. So the
copy parses `{"__proto__": {"admin": true}}` to `{}` whose `admin` reads `true`, where `JSON.parse`
keeps `__proto__` as a key. The fork defines the key instead.

## Override one rule in chevrotain

A chevrotain grammar is a class, and grammar inheritance replaces a rule with `OVERRIDE_RULE` in a
subclass. The JSON example in the chevrotain repository runs `performSelfAnalysis` in its own
constructor, after which no rule can be overridden. `chevrotain-json.mjs` makes two changes to it,
marked `CHANGED`, so that a subclass can make that call itself.

```js title="chevrotain-date.mjs"
// The lexer tries patterns in list order, so the date goes in front of the
// number, which would otherwise take the year and stop.
export const dateTokens = jsonTokens.flatMap((t) => (t === NumberLiteral ? [DateLiteral, t] : [t]))

export class DateJsonParser extends JsonParser {
  constructor() {
    super(dateTokens)
    const $ = this
    // OVERRIDE_RULE replaces the whole rule, so every alternative the base
    // had is restated here beside the new one.
    $.OVERRIDE_RULE('value', () => {
      $.OR([
        { ALT: () => $.CONSUME(DateLiteral) },
        { ALT: () => $.CONSUME(StringLiteral) },
        { ALT: () => $.CONSUME(NumberLiteral) },
        { ALT: () => $.SUBRULE($.object) },
        { ALT: () => $.SUBRULE($.array) },
        { ALT: () => $.CONSUME(True) },
        { ALT: () => $.CONSUME(False) },
        { ALT: () => $.CONSUME(Null) },
      ])
    })
    this.performSelfAnalysis()
  }
}
```

```bash
node compare.mjs
```

```text output
engines: @tabnas/json 0.5.18, peggy 5.1.1, chevrotain 13.2.0

document                    stock                 extended
{"due": 2026-09-02}         rejected              {"due":date(2026-09-02)}
[2026-09-02, 2026]          rejected              [date(2026-09-02),2026]
{"leap": 2024-02-29}        rejected              {"leap":date(2024-02-29)}
{"year": 2026}              {"year":2026}         {"year":2026}
{"id": "2026-09-02"}        {"id":"2026-09-02"}   {"id":"2026-09-02"}
{"due": 2026-9-2}           rejected              rejected
{"due": 2026-02-30}         rejected              rejected
{"due": 2026-09-02T10:00}   rejected              rejected

48 of 48 results as the corpus expects
```

Every document gets the same result from all three engines, in the stock grammars as in the
extended ones. The subclass restates seven of the eight alternatives in `value`, because
`OVERRIDE_RULE` replaces the rule's whole body. The token order matters as much as the rule. The chevrotain lexer
[takes the first pattern that matches, not the longest](https://chevrotain.io/docs/tutorial/step1_lexing.html#creating-the-lexer),
so `DateLiteral` sits in front of `NumberLiteral`.

## Check it worked

`extend.test.mjs` runs the corpus through each engine's stock and extended parsers and asserts
every result. It also checks that the tabnas plugin leaves the shape of the rule table alone, and
that chevrotain refuses an override on a base class that has already analyzed itself. Two more check
that every extended parser keeps `__proto__` as a key, and that `compare.mjs` exits 1 when a result
departs from the corpus, so it can gate a merge.

```bash
node --test extend.test.mjs
```

```text output
1..16
# tests 16
# suites 0
# pass 16
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 578.318689
```

Sixteen tests pass. The six whose names start with `mistake` pin the failures below, so a change
that silently fixes or worsens one of them fails the run.

## When it goes wrong

Each engine fails in its own way when the new literal and an old token compete for the same text.
The script runs each mistake for real.

```bash
node pitfall.mjs
```

```text output
mistake                                       document              result
tabnas: pattern also takes a bare year        {"year": 2026}        {"year":date(2026-01-01)}
tabnas: same pattern, converted by toDate     {"year": 2026}        rejected: [tabnas/unexpected]: unexpected character(s): 2
peggy: date listed after number               {"due": 2026-09-02}   rejected: Expected "," or "}" but "-" found.
chevrotain: DateLiteral after NumberLiteral   {"due": 2026-09-02}   rejected: Expecting token of type --> RCurly <-- but found --> '-0' <--
tabnas: a token and a val alternate           {"due": 2026-09-02}   {"due":date(2026-09-02)}
tabnas: a token and a val alternate           [2026-09-02]          rejected: [tabnas/unexpected]: unexpected character(s): 2
```

A number turns into a date. ISO 8601 allows a date cut down to its year,[^3] so a pattern written
to the standard also matches `2026`, and tabnas runs `match` patterns before its number matcher.
With a plain `new Date` the year becomes the first of January; with `toDate` the document fails.
Keep the pattern to the full form, and keep `{"year": 2026}` in the corpus.

The date never parses. peggy tries its choice and chevrotain its token list in order, so a number
listed first takes `2026` and leaves `-09-02` behind. List the longer literal first.

A date parses in an object and fails as the first element of an array. A token registered with
`match.token` is
[recognized only where the active rule expects it](https://github.com/tabnas/parser/blob/main/ts/doc/plugins.md#custom-matchers),
and the token after `[` is read during the `list` rule's two-token lookahead. Register a scalar
literal with `match.value`, which the lexer tries in every position.

## When not to do this

Do not extend JSON for data that crosses a boundary you do not own. Every reader of the file then
needs your parser, and a reader on `JSON.parse` fails at the first date. A string with a documented
format such as `"due": "2026-09-02"` costs one validation step and keeps the file JSON.

Do not use the tabnas plugin for a literal with structure, such as a tagged value with arguments.
A pattern recognizes a token, not a nested form, and that needs tokens of its own and alternates on
`val`, which is how `@tabnas/expr`, `@tabnas/directive`, and `@tabnas/hoover` extend a grammar.
Expect its errors to say `unexpected`, because a converter's own message does not reach the caller.

Do not fork for one literal. The 150 copied lines are where upstream's next fix fails to arrive. A
fork earns its keep once the dialect has drifted so far that upstream's fixes no longer apply.

## Related how-tos

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

- [Accept comments and unquoted keys in a JSON config file](/howto/accept-comments-and-unquoted-keys-in-json)

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

- [Parse one config format identically in TypeScript and Go](/howto/parse-one-config-format-in-typescript-and-go)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@tabnas/json` 0.5.18, `@tabnas/parser` 0.12.14, peggy
5.1.1, and chevrotain 13.2.0. Every output block is what the command preceding it printed.
`json.pegjs` is peggy's JSON example, copied unchanged. The Go port, `github.com/tabnas/json/go`
0.5.18, takes the same option as `tabnas.Options{Match: &tabnas.MatchOptions{Value: ...}}`; run by
hand against the eight documents, it gave the same verdicts, and it reported a converter's panic as
an internal error. It is not part of the checked output.

[^1]: The [ECMAScript date-time string format](https://tc39.es/ecma262/#sec-date-time-string-format)
that `Date` reads includes the date-only forms `YYYY`, `YYYY-MM`, and `YYYY-MM-DD`, and it bounds
`DD` at 01 to 31 in every month. By that bound `2026-02-30` is a well-formed date, and V8 counts on
from the first of February to the second of March. The same format makes `new Date('2026')` the
first of January 2026, which is the reading the loose pattern in the failure section gives a bare
year.

[^2]: The two upstream examples are dated by the standards they follow.
[peggy's grammar](https://github.com/peggyjs/peggy/blob/main/examples/json.pegjs) says it is based
on RFC 7159, which [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259.html) obsoleted in December
2017. [The chevrotain example](https://github.com/Chevrotain/chevrotain/blob/master/examples/grammars/json/json.js)
accepts only an object or an array at the top, the rule of
[RFC 4627](https://www.rfc-editor.org/rfc/rfc4627.html), which RFC 7159 dropped in March 2014. The
corpus here wraps every document in braces or brackets, so the older rule decides nothing.

[^3]: [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html#appendix-A) collects an ABNF for
ISO 8601 in an appendix, because ISO 8601 "does not specify a formal grammar" for the formats it
defines. A bare year is one of the dates in it. The appendix calls itself informational and allows
that it may contain errors. It also settles a conflict inside the standard by assuming that section
5.3.1.3 is correct and that Annex B.2 is in error.