How-to › Parse, validate and transform data

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.

Audience
Library maintainer
Level
advanced
Topic
Parse config and data formats
Languages
TypeScript and Go
Verified

A customer’s config file loads in your TypeScript library and fails in your Go CLI. The two parsers were written separately and are kept in step by memory, so every fix lands in one and waits for the other. Another file loads in both with different values: an account id of 9007199254740993 stays exact in Go and comes out as 9007199254740992 in JavaScript.

What you get

You will end up with one ABNF grammar that the TypeScript and the Go parser both compile, and one corpus of accept and reject fixtures that both CI jobs run. This is for you if two runtimes read the same config format.

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.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
js-yaml plus gopkg.in/yaml.v3The format is YAML and both libraries are already in your stackTwo readings of one specification, which gave different values for four of the five edge-case documents hereBoth sides must agree on edge cases you cannot list in advance
@tabnas/parser with @tabnas/abnfTwo runtimes must accept exactly the same files, and the format is yours to defineA parsing engine in each runtime, value handlers written twice, and a number rule you set yourselfOne runtime reads the format, or it is a standard with mature parsers on both sides
Two hand-written readersA tiny format, no dependency budget, and one team owning both sidesEvery fix is made twice, and drift surfaces as a file one side accepts and the other rejectsThe 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 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.

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

// 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 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

node check.ts
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
go run . check
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.

node compare.ts
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 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 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, plus two tests of the canonical text.

node --test corpus.test.ts
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.

go test -v -run TestCorpus .
=== 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.

node pitfall.ts
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.

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

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.

Footnotes

  1. Go’s JSON encoder says, in a comment on its float formatting, that it converts “as if by ES6 number to string conversion,” which matches most other JSON generators. The ECMAScript algorithm, Number::toString, returns “0” for negative zero. Go writes -0, which RFC 8259 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. ↩︎ Back to text

  2. gopkg.in/yaml.v3 resolves to 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 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. ↩︎ Back to text

  3. The ECMAScript specification uses the same number. Its note on 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. ↩︎ 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.