# How to parse one config format identically in TypeScript and Go

> Compile one ABNF grammar with tabnas into a TypeScript and a Go parser, and run one corpus of accept and reject fixtures in both CI jobs to prove they agree.

Source: https://voxgig.com/howto/parse-one-config-format-in-typescript-and-go

- Audience: library-maintainer
- Level: advanced
- Languages: typescript, go
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Write the format once in ABNF and compile it with @tabnas/abnf in both runtimes, so the TypeScript library and the Go CLI share one rule table. Bind the same mark names to small value handlers in each language, and print every result as one canonical text. Run a shared corpus of accept and reject fixtures, with expected values, in both CI jobs. Decide the number rule once, because both runtimes hold numbers as float64.

---
## You will need

Node 22.18 or later, which runs the TypeScript files without a build step, Go 1.24.7 or later, and
a config format both runtimes read. Verified 2026-10-21 against Node 22.22.0, Go 1.24.7,
`@tabnas/parser` 0.12.14, `@tabnas/abnf` 0.4.25, js-yaml 5.5.0, and gopkg.in/yaml.v3 3.0.1. The
Go module fetches its dependencies on first run. Binding handlers to grammar rules is covered in
[attaching 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 |
| --- | --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) plus [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) | The format is YAML and both libraries are already in your stack | Two readings of one specification, which gave different values for four of the five edge-case documents here | Both sides must agree on edge cases you cannot list in advance |
| [`@tabnas/parser` with `@tabnas/abnf`](https://tabnas.dev/docs/abnf-grammars/) | Two runtimes must accept exactly the same files, and the format is yours to define | A parsing engine in each runtime, value handlers written twice, and a number rule you set yourself | One runtime reads the format, or it is a standard with mature parsers on both sides |
| [Two hand-written readers](https://pkg.go.dev/strconv) | A tiny format, no dependency budget, and one team owning both sides | Every fix is made twice, and drift surfaces as a file one side accepts and the other rejects | The format will grow, or the two sides ship on different schedules |

Voxgig maintains tabnas. This page compares it with js-yaml plus gopkg.in/yaml.v3 and with two
hand-written readers.

The YAML pair costs nothing to adopt, but two libraries read one specification differently, so
testing one against the other falls to you. Hand-written readers add no dependency, at the price
of every fix landing twice. tabnas removes the second grammar, at the price of an engine in each
runtime. The handlers that turn tokens into values are still written once per language, so the
corpus has to check them too.

## Write the format once, in ABNF

`settings.abnf` is the whole format: a key, an equals sign, and a value that is a number, a quoted
string, or a literal such as `true`. The notation is [RFC 5234](https://www.rfc-editor.org/rfc/rfc5234.html)
ABNF.

```text title="settings.abnf"
doc  = 1*pair
pair = key EQ val
key  = TX
val  = NR / ST / VL
EQ   = "="
```

`TX`, `NR`, `ST`, and `VL` are the engine's built-in tokens for bare text, numbers, strings, and
literals. The TypeScript and Go lexers each implement them, and the corpus checks that the two
agree. Each runtime compiles the file and binds handlers to the same mark names.

```ts title="config.ts"
// Handlers keyed by the marks the compiler assigns. config.go binds the same names.
const ACTIONS = {
  '@val:o:NR': (r: any) => { r.node.value = r.o0.val },
  '@val:o:ST': (r: any) => { r.node.value = r.o0.val },
  '@val:o:VL': (r: any) => { r.node.value = r.o0.val },
  '@pair:ac': (r: any) => { r.node.value = [r.o0.src, r.child.node.value] },
  '@doc:ac': (r: any) => { r.node.value = Object.fromEntries(r.node.kids.map((k: any) => k.value)) },
}

const parser = new Tabnas({ plugins: [abnf] })
parser.abnf(GRAMMAR, { actions: ACTIONS })
```

The Go side embeds the same file with `//go:embed` and installs it with `abnf.Install`, the Go
form of the `abnf()` call.

```go title="config.go"
// The names config.ts binds, with the same handlers written in Go.
var actions = abnf.ActionsMap{
	"@val:o:NR": setValue,
	"@val:o:ST": setValue,
	"@val:o:VL": setValue,
	"@pair:ac": {func(r *tabnas.Rule, ctx *tabnas.Context) {
		node(r)["value"] = []any{r.O[0].Src, node(r.Child)["value"]}
	}},
	"@doc:ac": {func(r *tabnas.Rule, ctx *tabnas.Context) {
		config := map[string]any{}
		kids, _ := node(r)["kids"].([]any)
		for _, kid := range kids {
			pair := kid.(map[string]any)["value"].([]any)
			config[pair[0].(string)] = pair[1]
		}
		node(r)["value"] = config
	}},
}

var parser = func() *tabnas.Tabnas {
	j := tabnas.Make()
	if _, err := abnf.Install(j, grammar, nil, actions); err != nil {
		panic(err) // the grammar is part of the program, so a bad one is a bug
	}
	return j
}()
```

The handlers are the code that still exists twice. Keep them this small, so a reviewer can read
both at once, and let the [tabnas actions guide](https://tabnas.dev/docs/actions/) settle what each
rule instance field holds.

## Run one corpus in both CI jobs

The corpus is ten files. Five under `accept/` each carry the canonical JSON they must produce, and
five under `reject/` must fail. Each runtime prints one line per fixture, with keys sorted and
negative zero written as `0`, so the two outputs can be compared byte for byte.[^1]

```bash
node check.ts
```

```text output
accept/basic.cfg           ok       {"debug":true,"name":"api","port":8080}
accept/comments.cfg        ok       {"host":"gw#1","retries":3}
accept/escapes.cfg         ok       {"greeting":"café","quote":"it's"}
accept/hex.cfg             ok       {"mask":31}
accept/negative-zero.cfg   ok       {"offset":0}
reject/big-id.cfg          ok       rejected
reject/list.cfg            ok       rejected
reject/missing-value.cfg   ok       rejected
reject/overflow.cfg        ok       rejected
reject/unterminated.cfg    ok       rejected
10 fixtures, 10 as expected
```

```bash
go run . check
```

```text output
accept/basic.cfg           ok       {"debug":true,"name":"api","port":8080}
accept/comments.cfg        ok       {"host":"gw#1","retries":3}
accept/escapes.cfg         ok       {"greeting":"café","quote":"it's"}
accept/hex.cfg             ok       {"mask":31}
accept/negative-zero.cfg   ok       {"offset":0}
reject/big-id.cfg          ok       rejected
reject/list.cfg            ok       rejected
reject/missing-value.cfg   ok       rejected
reject/overflow.cfg        ok       rejected
reject/unterminated.cfg    ok       rejected
10 fixtures, 10 as expected
```

The two blocks are identical. The corpus also pins choices the grammar makes without saying so:
`0x1F` is 31 in both runtimes, a `#` inside quotes is data, and a single-quoted string may escape
its own quote. A change to either engine's lexer that moves one of those fails a fixture.

## Measure the alternatives on the same corpus

`compare.ts` runs the corpus through the hand-written readers in both languages, and five YAML
documents through js-yaml and gopkg.in/yaml.v3. It gets the Go results from `go run . dump`.

```bash
node compare.ts
```

```text output
fixture                    tabnas            hand-written
accept/basic.cfg           same              same
accept/comments.cfg        same              same, both wrong
accept/escapes.cfg         same              go rejects
accept/hex.cfg             same              go rejects
accept/negative-zero.cfg   same              same
reject/big-id.cfg          same              values differ
reject/list.cfg            same              same
reject/missing-value.cfg   same              same
reject/overflow.cfg        same              same
reject/unterminated.cfg    same              same

yaml document  js-yaml                        gopkg.in/yaml.v3
count.yaml     {"count":"1_000"}              {"count":1000}
id.yaml        {"id":9007199254740992}        {"id":9007199254740993}
mode.yaml      {"mode":755}                   {"mode":493}
version.yaml   {"version":1.1}                {"version":1.1}
when.yaml      {"when":"2024-03-04"}          {"when":"2024-03-04T00:00:00Z"}
```

The hand-written pair splits on three files. Go's
[`strconv.Unquote`](https://pkg.go.dev/strconv#Unquote) reads a single-quoted string as a
one-character literal, so it refuses `'it\'s'`, and `strconv.ParseInt` refuses `0x1F`. Both readers
cut a line at its first `#`, so they agree on the comment fixture and are both wrong, which only an
expected value catches.

The YAML pair gives different values for four of the five documents. The
[YAML 1.2 specification](https://yaml.org/spec/1.2.2/) writes octal with a `0o` prefix, so js-yaml
reads `0755` as 755, and yaml.v3 read it as 493. Both turned `version: 1.10` into the number 1.1,
which is agreement on a value nobody meant.[^2]

## Check it worked

Run the corpus the way each CI job does. The TypeScript job runs the fixtures with the
[Node test runner](https://nodejs.org/api/test.html), plus two tests of the canonical text.

```bash
node --test corpus.test.ts
```

```text output
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 226.208341
```

The Go job runs the same ten files, one test per file, and logs a summary only when none failed.

```bash
go test -v -run TestCorpus .
```

```text output
=== RUN   TestCorpus/reject/missing-value.cfg
=== RUN   TestCorpus/reject/overflow.cfg
=== RUN   TestCorpus/reject/unterminated.cfg
=== NAME  TestCorpus
    corpus_test.go:22: 10 fixtures, all as expected in Go
```

`# pass 12` in TypeScript and `10 fixtures, all as expected in Go` in Go are the two results to
expect. A pull request that changes the grammar has to keep both.

## When it goes wrong

A large integer loads in both runtimes and means two different things. The hand-written Go reader
keeps 9007199254740993 exact in an `int64`, and JavaScript rounds it.[^3] tabnas has the subtler
version of the bug: both runtimes hold numbers as float64, so they agree, on the rounded value.

```bash
node pitfall.ts
```

```text output
account = 9007199254740993
                           typescript                    go
tabnas, number rule off    {"account":9007199254740992}  {"account":9007199254740992}
hand-written               {"account":9007199254740992}  {"account":9007199254740993}
tabnas, number rule on     rejected                      rejected
```

Agreement between runtimes is not correctness. `parseConfig` in each language refuses any number
beyond 2^53 − 1, and the corpus expects `big-id.cfg` to be rejected. If the format needs larger
ids, require them as quoted strings and add a fixture that says so.

```ts title="config.ts"
// Both runtimes read numbers as float64, so both refuse one a float64 cannot hold exactly.
export function parseConfig(src: string): Config {
  const config = parseUnchecked(src)
  for (const [key, value] of Object.entries(config)) {
    if (typeof value === 'number' && !(Math.abs(value) <= Number.MAX_SAFE_INTEGER)) {
      throw new Error(`${key}: outside the range both runtimes hold exactly`)
    }
  }
  return config
}
```

Two runtimes disagree on a file both parsed the same way. The comparison text is the cause:
`JSON.stringify` writes negative zero as `0`, Go writes `-0`, and Go escapes `<`, `>`, and `&`
unless told not to. Go also escapes U+2028 and U+2029 whatever it is told, and sorts keys by UTF-8
byte, where `sort()` compares UTF-16 units. Define the canonical text in both languages, as
`canonical` does, and test both with the same input and the same expected text.

## When not to do this

Do not adopt tabnas to share a format that already has a standard and good parsers on both sides.
For JSON, run the corpus against `JSON.parse` and `encoding/json` before you write a grammar.

Do not treat a shared grammar as the end of the work. The handlers, the number rule, and the
canonical text are code in two languages, and only the corpus keeps them in step.

Do not let the corpus grow only from bug reports. Add a fixture for every choice the format makes
on purpose, hex numbers and `#` comments included, so a lexer change in a tabnas release fails a
test before it reaches a user.

Do not keep two hand-written readers past a handful of keys. Each fix in one becomes a change in
the other, and the drift arrives as a file one side accepts.

## Related how-tos

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

- [Decide when lenient parsing is the wrong choice](/howto/decide-when-lenient-parsing-is-wrong)

- [Include one config file from another](/howto/include-one-config-file-from-another)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, `@tabnas/parser` 0.12.14 and `@tabnas/abnf`
0.4.25 with their Go modules at the same versions, js-yaml 5.5.0, and gopkg.in/yaml.v3 3.0.1.
Every output block is what the command preceding it printed, run in the page's code directory
after `npm ci`. The two CI jobs are shown as local commands, not as a workflow run.

[^1]: Go's JSON encoder says, in a
[comment on its float formatting](https://github.com/golang/go/blob/go1.24.7/src/encoding/json/encode.go#L546),
that it converts "as if by ES6 number to string conversion," which matches most other JSON
generators. The ECMAScript algorithm, [Number::toString](https://tc39.es/ecma262/#sec-numeric-types-number-tostring),
returns "0" for negative zero. Go writes `-0`, which [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259.html#section-6)
allows, since its grammar puts an optional minus before any integer, zero included. The comment is
about where the exponent starts. Negative zero is the case it does not reach.

[^2]: gopkg.in/yaml.v3 resolves to [github.com/go-yaml/yaml](https://github.com/go-yaml/yaml),
which its owner archived on April 1, 2025, after marking the project unmaintained. Its last release,
v3.0.1, dates from May 2022. The YAML organization carries the code on in
[a fork](https://github.com/yaml/go-yaml) that, its README says, it took over after discussion with
the original author. That fork keeps v3 as frozen legacy and asks new projects to import
go.yaml.in/yaml/v4.

[^3]: The ECMAScript specification uses the same number. Its note on
[`Number.MAX_SAFE_INTEGER`](https://tc39.es/ecma262/#sec-number.max_safe_integer) says every
integer greater than that constant shares its Number value with at least one other, and gives as its
example that both 9007199254740992 and 9007199254740993 evaluate to 9007199254740992. The fixture
here is 2^53 + 1, the smallest positive integer a float64 cannot hold, which is how a test corpus
and a language standard arrived at the same sixteen digits.