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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| js-yaml plus 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 | 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 | 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
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.
Related how-tos
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
-
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 -
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
-
The ECMAScript specification uses the same number. Its note on
Number.MAX_SAFE_INTEGERsays 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