---
title: "Conditions and counters · Docs · tabnas"
description: "Pick an alternate on state rather than tokens, with a predicate, a counter, or a declarative check."
source: "https://tabnas.dev/docs/conditions/"
---

# Conditions and counters

Alternates are chosen by their tokens. When two cases look identical to the lexer, the difference has to come from somewhere else: how deep you are, what the enclosing rule is, whether something has already been seen.

That is what `c` is for. This tutorial covers the three ways to write one, and how counters read before anything has been counted.

## A condition is a predicate

`c` is checked when an alternate’s tokens match. If it returns false, the alternate is skipped and the next one is tried:

**A condition is a predicate**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

const tn = new Tabnas()
tn.options({
  fixed: { token: { '#OP': '(', '#CP': ')' } },
  rule: { start: 'val' },
})

const OP = tn.token('#OP')
const setNumber = (r: any) => { r.node = r.o0.val }

// `c` is checked when an alternate's tokens match. If it returns false the
// alternate is skipped and the next one is tried.
tn.rule('val', (rs: any) => rs
  .open([{ s: '#OP', p: 'val' }, { s: '#NR', a: setNumber }])
  .close([
    // Only a val that opened on '(' may consume ')'.
    { s: '#CP', c: (r: any) => OP === r.o0?.tin, a: (r: any) => { r.node = [r.child.node] } },
    {},
  ]))

for (const src of ['1', '(1)', '((1))']) {
  console.log(src.padEnd(6), '=>', JSON.stringify(tn.parse(src)))
}
```

Go:

```go
package main

import (
	"encoding/json"
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

func main() {
	j := tabnas.Make()
	j.SetOptions(tabnas.MapToOptions(map[string]any{
		"fixed": map[string]any{"token": map[string]any{"#OP": "(", "#CP": ")"}},
		"rule":  map[string]any{"start": "val"},
	}))

	OP, CP, NR := j.Token("#OP"), j.Token("#CP"), j.Token("#NR")
	setNumber := func(r *tabnas.Rule, ctx *tabnas.Context) { r.Node = r.O0.Val }

	// `C` is checked when an alternate's tokens match. If it returns false the
	// alternate is skipped and the next one is tried.
	j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) {
		rs.AddOpen(
			&tabnas.AltSpec{S: [][]tabnas.Tin{{OP}}, P: "val"},
			&tabnas.AltSpec{S: [][]tabnas.Tin{{NR}}, A: setNumber},
		)
		rs.AddClose(
			// Only a val that opened on '(' may consume ')'.
			&tabnas.AltSpec{
				S: [][]tabnas.Tin{{CP}},
				C: func(r *tabnas.Rule, ctx *tabnas.Context) bool { return OP == r.O0.Tin },
				A: func(r *tabnas.Rule, ctx *tabnas.Context) { r.Node = []any{r.Child.Node} },
			},
			&tabnas.AltSpec{},
		)
	})

	for _, src := range []string{"1", "(1)", "((1))"} {
		out, err := j.Parse(src)
		if err != nil {
			panic(err)
		}
		b, _ := json.Marshal(out)
		fmt.Printf("%-6s => %s\n", src, string(b))
	}
}
```

Explain: Both `val` alternates look the same to the lexer at the closing bracket, so the difference has to come from state. `c` is checked once an alternate's tokens match: only a `val` that opened on `(` may consume `)`, and a bare number falls through to the empty alternate. Everything on the rule instance is available inside `c`: `r.o0`, `r.parent`, `r.child`, `r.n`, `r.u`.

Output: `1 => 1 · (1) => [1] · ((1)) => [[1]]`

Everything on [the rule instance](https://tabnas.dev/docs/rule-instance/) is available: `r.o0` for the token that opened this rule, `r.parent`, `r.child`, `r.n`, `r.u`.

## Counters

`n` on an alternate sets or increments a named counter, and counters **propagate to pushed and repeated rules**, so a count made at the top is visible all the way down:

**Add one, or reset to zero**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

let seen = 0

// `n` on an alternate sets or increments a named counter, and counters
// propagate to pushed and repeated rules. Setting 0 RESETS; any other number
// adds.
function build(step: number) {
  const tn = new Tabnas()
  tn.options({
    fixed: { token: { '#OP': '(', '#CP': ')' } },
    rule: { start: 'val' },
  })
  tn.rule('val', (rs: any) => rs
    .open([
      { s: '#OP', p: 'val', n: { depth: step } },
      { s: '#NR', a: (r: any) => { seen = r.n.depth ?? 0 } },
    ])
    .close([{ s: '#CP' }, {}]))
  return tn
}

function depthAt(step: number, src: string) {
  build(step).parse(src)
  return seen
}

for (const src of ['1', '(1)', '((1))']) {
  console.log(`n:{depth:1}  ${src.padEnd(6)} depth at the number =`, depthAt(1, src))
}
console.log('n:{depth:0}  ((1))  depth at the number =', depthAt(0, '((1))'))
```

Go:

```go
package main

import (
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

var seen int

// `N` on an alternate sets or increments a named counter, and counters
// propagate to pushed and repeated rules. Setting 0 RESETS; any other number
// adds.
func build(step int) *tabnas.Tabnas {
	j := tabnas.Make()
	j.SetOptions(tabnas.MapToOptions(map[string]any{
		"fixed": map[string]any{"token": map[string]any{"#OP": "(", "#CP": ")"}},
		"rule":  map[string]any{"start": "val"},
	}))
	OP, CP, NR := j.Token("#OP"), j.Token("#CP"), j.Token("#NR")

	j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) {
		rs.AddOpen(
			&tabnas.AltSpec{S: [][]tabnas.Tin{{OP}}, P: "val", N: map[string]int{"depth": step}},
			&tabnas.AltSpec{S: [][]tabnas.Tin{{NR}}, A: func(r *tabnas.Rule, ctx *tabnas.Context) {
				seen = r.N["depth"]
			}},
		)
		rs.AddClose(&tabnas.AltSpec{S: [][]tabnas.Tin{{CP}}}, &tabnas.AltSpec{})
	})
	return j
}

func depthAt(step int, src string) int {
	if _, err := build(step).Parse(src); err != nil {
		panic(err)
	}
	return seen
}

func main() {
	for _, src := range []string{"1", "(1)", "((1))"} {
		fmt.Printf("n:{depth:1}  %-6s depth at the number = %d\n", src, depthAt(1, src))
	}
	fmt.Println("n:{depth:0}  ((1))  depth at the number =", depthAt(0, "((1))"))
}
```

Explain: `n` on an alternate sets or increments a named counter, and counters propagate to pushed and repeated rules, so a count made at the top is visible all the way down. The last line is the trap: setting `0` **resets** rather than adding nothing, so two levels of nesting still report `0`. Reading `n: { pk: 0 }` in the JSON grammar as a no-op will mislead you.

Output: `n:{depth:1} 1 depth at the number = 0 · n:{depth:1} (1) depth at the number = 1 · n:{depth:1} ((1)) depth at the number = 2 · n:{depth:0} ((1)) depth at the number = 0`

Setting `0` **resets**; any other number adds. `n: { pk: 0 }` in the JSON grammar is a reset, and reading it as a no-op will mislead you.

The rule instance has comparison helpers:

| Helper | True when |
| --- | --- |
| `r.eq('k', n)` | the counter equals `n` |
| `r.lt('k', n)` `r.lte` | below / at most `n` |
| `r.gt('k', n)` `r.gte` | above / at least `n` |

## An unset counter reads as zero

A counter that has never been incremented has counted nothing, so it compares as `0`:

**Nothing counted yet**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

const tn = new Tabnas()
tn.options({ rule: { start: 'val' } })

// Nothing has ever incremented `depth`, so it has counted nothing.
tn.rule('val', (rs: any) => rs.open([{ s: '#NR', a: (r: any) => {
  console.log("lt('depth', 3)   ", r.lt('depth', 3))
  console.log("eq('depth', 0)   ", r.eq('depth', 0))
  console.log("gt('depth', 3)   ", r.gt('depth', 3))
  console.log("exist('depth')   ", r.exist('depth'))
} }]))

tn.parse('42')
```

Go:

```go
package main

import (
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

func main() {
	j := tabnas.Make()
	j.SetOptions(tabnas.MapToOptions(map[string]any{
		"rule": map[string]any{"start": "val"},
	}))
	NR := j.Token("#NR")

	// Nothing has ever incremented `depth`, so it has counted nothing.
	j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) {
		rs.AddOpen(&tabnas.AltSpec{S: [][]tabnas.Tin{{NR}}, A: func(r *tabnas.Rule, ctx *tabnas.Context) {
			fmt.Println("lt('depth', 3)   ", r.Lt("depth", 3))
			fmt.Println("eq('depth', 0)   ", r.Eq("depth", 0))
			fmt.Println("gt('depth', 3)   ", r.Gt("depth", 3))
			fmt.Println("exist('depth')   ", r.Exist("depth"))
		}})
	})

	if _, err := j.Parse("42"); err != nil {
		panic(err)
	}
}
```

Explain: A counter that was never incremented has counted nothing, so it compares as `0`: below a limit, not past one, and equal to zero. Exactly one of `<`, `=`, `>` holds, so a guard means what it says wherever you put it. `exist` is the one question the comparisons cannot answer: a counter set to `0` and one never set compare identically.

Output: `lt('depth', 3) true · eq('depth', 0) true · gt('depth', 3) false · exist('depth') false`

That keeps the permissive direction you want (a rule that never counts is not blocked by a limit it knows nothing about) while leaving exactly one of `<`, `=`, `>` true, so a guard means what it says wherever you put it.

`eq('k', 0)` is therefore true both for a counter set to `0` and for one never set. When the difference matters, ask directly:

**Never set, or counted zero?**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

let existed = false
let isZero = false

// `eq('k', 0)` is true both for a counter set to 0 and for one never set.
// `exist` is the only way to tell those two apart.
function build(step: number) {
  const tn = new Tabnas()
  tn.options({
    fixed: { token: { '#OP': '(', '#CP': ')' } },
    rule: { start: 'val' },
  })
  tn.rule('val', (rs: any) => rs
    .open([
      { s: '#OP', p: 'val', n: { depth: step } },
      { s: '#NR', a: (r: any) => { existed = r.exist('depth'); isZero = r.eq('depth', 0) } },
    ])
    .close([{ s: '#CP' }, {}]))
  return tn
}

const cases: [string, number, string][] = [
  ['never set', 1, '1'],
  ['set to 0 ', 0, '(1)'],
  ['counted 1', 1, '(1)'],
]
for (const [label, step, src] of cases) {
  build(step).parse(src)
  console.log(label, ' exist:', String(existed).padEnd(5), ' eq(depth,0):', isZero)
}
```

Go:

```go
package main

import (
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

var existed, isZero bool

// `Eq("k", 0)` is true both for a counter set to 0 and for one never set.
// `Exist` is the only way to tell those two apart.
func build(step int) *tabnas.Tabnas {
	j := tabnas.Make()
	j.SetOptions(tabnas.MapToOptions(map[string]any{
		"fixed": map[string]any{"token": map[string]any{"#OP": "(", "#CP": ")"}},
		"rule":  map[string]any{"start": "val"},
	}))
	OP, CP, NR := j.Token("#OP"), j.Token("#CP"), j.Token("#NR")

	j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) {
		rs.AddOpen(
			&tabnas.AltSpec{S: [][]tabnas.Tin{{OP}}, P: "val", N: map[string]int{"depth": step}},
			&tabnas.AltSpec{S: [][]tabnas.Tin{{NR}}, A: func(r *tabnas.Rule, ctx *tabnas.Context) {
				existed, isZero = r.Exist("depth"), r.Eq("depth", 0)
			}},
		)
		rs.AddClose(&tabnas.AltSpec{S: [][]tabnas.Tin{{CP}}}, &tabnas.AltSpec{})
	})
	return j
}

func main() {
	cases := []struct {
		label string
		step  int
		src   string
	}{
		{"never set", 1, "1"},
		{"set to 0 ", 0, "(1)"},
		{"counted 1", 1, "(1)"},
	}
	for _, c := range cases {
		if _, err := build(c.step).Parse(c.src); err != nil {
			panic(err)
		}
		fmt.Printf("%s  exist: %-5v  eq(depth,0): %v\n", c.label, existed, isZero)
	}
}
```

Explain: The middle two rows are the point: a counter never set and a counter reset to `0` compare identically, so `eq('depth', 0)` cannot tell them apart. `exist` can, and it is the only thing that can. Reach for it whenever you mean "only if this was explicitly set" rather than "whatever it counts to".

Output: `never set exist: false eq(depth,0): true · set to 0 exist: true eq(depth,0): true · counted 1 exist: true eq(depth,0): false`

> **Changed in 0.6.** Previously an unset counter compared as **true against every helper**, so `lt('depth',3)` and `gt('depth',3)` were both true and a `$gte` guard fired on the very first token, before anything had been counted.
> 
> An alternate gated on `{ 'n.k': { $gt: 0 } }` used to match on **two** grounds: the counter was positive, _or_ it was unset and the comparison passed regardless. Only the second is gone. If you relied on both, add the unset case as its own alternate: do not swap the condition, or you lose the positive-counter branch you actually wanted:
> 
> ```ts
> { s: '#CB', c: { 'n.k': { $gt: 0 } },        b: 1 }   // keep this
> { s: '#CB', c: { 'n.k': { $exist: false } }, b: 1 }   // add this if you
>                                                        // relied on fail-open
> ```
> 
> Paths that are not counters are unaffected: an absent `o0` or a `u.*` you never set is genuine absence, not zero.

## A depth limit, done properly

Nesting is allowed while `depth` is below the limit. Past it, neither push alternate matches, and the unconditional guard behind them is reached:

**A depth limit, done properly**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'
import { json } from '@tabnas/json'

const MAX = 3
const tn = new Tabnas({ plugins: [json] })
tn.options({ error: { too_deep: 'nested deeper than {max} levels' } })

// Nesting is allowed while `depth` is below the limit. Past it neither push
// alternate matches, and the unconditional guard behind them is reached.
tn.rule('val', (rs: any) => rs.open(
  [{ s: [['#OB', '#OS']], b: 1, e: (r: any) => r.o0.bad('too_deep', { max: MAX }) }],
  {
    custom: (alts: any[]) => {
      const guard = alts.shift()          // the alternate just prepended
      alts[0].n = alts[1].n = { depth: 1 }               // map and list push
      alts[0].c = alts[1].c = { 'n.depth': { $lt: MAX } }
      alts.splice(2, 0, guard)            // guard sits behind them
      return alts
    },
  },
))

// Order matters: alternates are tried in order and the first match wins, so a
// guard placed first would claim the token before the pushes are considered.
for (const src of [
  '{"a":1}', '{"a":{"b":{"c":1}}}', '[[[1]]]',
  '{"a":{"b":{"c":{"d":1}}}}', '[[[[1]]]]',
]) {
  try {
    console.log(src.padEnd(26), '=>', JSON.stringify(tn.parse(src)))
  } catch (e: any) {
    console.log(src.padEnd(26), '=>', e.code)
  }
}
```

Go:

```go
package main

import (
	"encoding/json"
	"errors"
	"fmt"

	tabnasjson "github.com/tabnas/json/go"
	tabnas "github.com/tabnas/parser/go"
)

const MAX = 3

func main() {
	j := tabnas.Make()
	if err := j.Use(tabnasjson.Json); err != nil {
		panic(err)
	}
	j.SetOptions(tabnas.MapToOptions(map[string]any{
		"error": map[string]any{"too_deep": "nested deeper than {max} levels"},
	}))
	OB, OS := j.Token("#OB"), j.Token("#OS")

	// Nesting is allowed while `depth` is below the limit. Past it neither push
	// alternate matches, and the unconditional guard behind them is reached.
	j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) {
		guard := &tabnas.AltSpec{
			S: [][]tabnas.Tin{{OB, OS}},
			B: 1,
			E: func(r *tabnas.Rule, ctx *tabnas.Context) *tabnas.Token {
				return r.O0.Bad("too_deep", map[string]any{"max": MAX})
			},
		}
		rs.ModifyOpen(&tabnas.AltModListOpts{
			Custom: func(alts []*tabnas.AltSpec) []*tabnas.AltSpec {
				for _, alt := range alts[:2] { // the map and list push alternates
					alt.N = map[string]int{"depth": 1}
					alt.CD = map[string]any{"n.depth": tabnas.CLt(MAX)}
					if err := tabnas.NormAlt(alt); err != nil {
						panic(err)
					}
				}
				// The guard sits BEHIND them: alternates are tried in order, so
				// putting it first would claim the token every time.
				return append(alts[:2:2], append([]*tabnas.AltSpec{guard}, alts[2:]...)...)
			},
		})
	})

	for _, src := range []string{
		`{"a":1}`, `{"a":{"b":{"c":1}}}`, `[[[1]]]`,
		`{"a":{"b":{"c":{"d":1}}}}`, `[[[[1]]]]`,
	} {
		out, err := j.Parse(src)
		if err != nil {
			var te *tabnas.TabnasError
			errors.As(err, &te)
			fmt.Printf("%-26s => %s\n", src, te.Code)
			continue
		}
		b, _ := json.Marshal(out)
		fmt.Printf("%-26s => %s\n", src, string(b))
	}
}
```

Explain: A depth limit done properly: the two push alternates carry the counter and the `$lt` guard, and an unconditional alternate that raises `too_deep` sits **behind** them. Alternates are tried in order, so a guard placed first would claim the opening bracket before either push was ever considered. Because an unset counter reads as `0`, the first bracket passes `0 < 3` and nesting proceeds; the fourth finds `3 < 3` false and falls through to the guard. Go reaches the same alternates through `ModifyOpen` and reads the failure code off a returned `*TabnasError` rather than catching a throw.

Output: `{"a":1} => {"a":1} · {"a":{"b":{"c":1}}} => {"a":{"b":{"c":1}}} · [[[1]]] => [[[1]]] · {"a":{"b":{"c":{"d":1}}}} => too_deep · [[[[1]]]] => too_deep`

Note the order: the two push alternates carry the condition, and the guard is _after_ them. Alternates are tried in order, so the first one that matches wins: put the guard first and it claims the token before the push alternates are ever considered.

A `$gte` guard first now also works, since an unset `depth` reads as `0` and `0 >= MAX` is false at the opening brace. Before 0.6 it did not: `$gte` passed unconditionally while the counter was unset, so every parse failed on the first token. Ordering the permissive alternates first is still the clearer habit: it does not depend on how the counter reads when nothing has been counted.

## Declarative conditions

`c` also takes an **object**, which is a check against a path on the rule instance. This keeps a grammar as data: no closure, so it still serialises:

**A guard with no closure in it**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

// `c` also takes an OBJECT: a check against a dot-path on the rule instance.
// No closure, so the grammar is still data — this one has no functions at all.
const tn = new Tabnas()
tn.grammar({
  options: {
    fixed: { token: { '#OP': '(', '#CP': ')' } },
    rule: { start: 'val' },
  },
  rule: {
    val: {
      open: [
        { s: '#OP', p: 'val', n: { depth: 1 }, c: { 'n.depth': { $lt: 3 } } },
        { s: '#NR' },
      ],
      close: [{ s: '#CP' }, {}],
    },
  },
})

// Past three levels the push alternate no longer applies, and nothing else
// matches an opening bracket.
for (const src of ['1', '(1)', '((1))', '(((1)))', '((((1))))']) {
  let verdict = 'accepted'
  try { tn.parse(src) } catch { verdict = 'rejected' }
  console.log(src.padEnd(10), verdict)
}
```

Go:

```go
package main

import (
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

func main() {
	// `C` also takes an OBJECT: a check against a dot-path on the rule instance.
	// No closure, so the grammar is still data — this one has no functions at all.
	j := tabnas.Make()
	err := j.Grammar(&tabnas.GrammarSpec{
		OptionsMap: map[string]any{
			"fixed": map[string]any{"token": map[string]any{"#OP": "(", "#CP": ")"}},
			"rule":  map[string]any{"start": "val"},
		},
		Rule: map[string]*tabnas.GrammarRuleSpec{
			"val": {
				Open: []*tabnas.GrammarAltSpec{
					{S: "#OP", P: "val", N: map[string]int{"depth": 1},
						C: map[string]any{"n.depth": tabnas.CLt(3)}},
					{S: "#NR"},
				},
				Close: []*tabnas.GrammarAltSpec{{S: "#CP"}, {}},
			},
		},
	})
	if err != nil {
		panic(err)
	}

	// Past three levels the push alternate no longer applies, and nothing else
	// matches an opening bracket.
	for _, src := range []string{"1", "(1)", "((1))", "(((1)))", "((((1))))"} {
		verdict := "accepted"
		if _, err := j.Parse(src); err != nil {
			verdict = "rejected"
		}
		fmt.Printf("%-10s %s\n", src, verdict)
	}
}
```

Explain: The object form of `c` is a check against a dot-path on the rule instance: `n.depth` is the counter, `u.mode` your own scratch value, `o0.tin` the opening token. There is no closure, so the grammar remains data: serialisable, diffable, and checkable before you run it. Use it for anything that is a comparison against rule state, which is most guards. Anything involving two paths, arithmetic, or a call into your own code needs a function, and the grammar becomes code at that point.

Output: `1 accepted · (1) accepted · ((1)) accepted · (((1))) accepted · ((((1)))) rejected`

The key is a dot-path resolved against `r`, so `n.depth` is the counter, `u.mode` is your own scratch value, `o0.tin` is the opening token.

| Form | Meaning |
| --- | --- |
| `{ 'n.depth': { $lt: 3 } }` | below |
| `$lte` `$gt` `$gte` | at most / above / at least |
| `{ 'u.mode': 'strict' }` | a bare value is `$eq` |
| `{ 'u.mode': { $ne: 'loose' } }` | not equal |

Several keys are **ANDed**: every one must hold:

**Every key must hold**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

let mark = ''

// Several keys in one `c` are ANDed — every one must hold.
function check(depth: number, mode: string) {
  const tn = new Tabnas()
  tn.grammar({
    ref: {
      '@taken': () => { mark = 'taken' },
      '@skipped': () => { mark = 'skipped' },
    },
    options: { rule: { start: 'val' } },
    rule: {
      val: {
        open: [{ s: '#NR', n: { depth }, u: { mode } }],
        close: [
          { c: { 'n.depth': { $gte: 1 }, 'u.mode': 'strict' }, a: '@taken' },
          { a: '@skipped' },
        ],
      },
    },
  } as any)
  tn.parse('1')
  return mark
}

console.log('depth=1 mode=strict ', check(1, 'strict'))
console.log('depth=1 mode=loose  ', check(1, 'loose'))
console.log('depth=0 mode=strict ', check(0, 'strict'))
```

Go:

```go
package main

import (
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

var mark string

// Several keys in one `C` are ANDed — every one must hold.
func check(depth int, mode string) string {
	j := tabnas.Make()
	err := j.Grammar(&tabnas.GrammarSpec{
		Ref: map[tabnas.FuncRef]any{
			"@taken":   tabnas.AltAction(func(r *tabnas.Rule, ctx *tabnas.Context) { mark = "taken" }),
			"@skipped": tabnas.AltAction(func(r *tabnas.Rule, ctx *tabnas.Context) { mark = "skipped" }),
		},
		OptionsMap: map[string]any{"rule": map[string]any{"start": "val"}},
		Rule: map[string]*tabnas.GrammarRuleSpec{
			"val": {
				Open: []*tabnas.GrammarAltSpec{
					{S: "#NR", N: map[string]int{"depth": depth}, U: map[string]any{"mode": mode}},
				},
				Close: []*tabnas.GrammarAltSpec{
					{C: map[string]any{"n.depth": tabnas.CGte(1), "u.mode": tabnas.CEq("strict")}, A: "@taken"},
					{A: "@skipped"},
				},
			},
		},
	})
	if err != nil {
		panic(err)
	}
	if _, err := j.Parse("1"); err != nil {
		panic(err)
	}
	return mark
}

func main() {
	fmt.Println("depth=1 mode=strict ", check(1, "strict"))
	fmt.Println("depth=1 mode=loose  ", check(1, "loose"))
	fmt.Println("depth=0 mode=strict ", check(0, "strict"))
}
```

Explain: Several keys in one `c` are ANDed: the alternate applies only when every one holds. Drop either half (the wrong mode, or a counter that has not reached one) and the guarded alternate is skipped and the next one is tried. `u` survives from a rule's open phase into its close phase, which is why the mode set on the way down is readable on the way back up. TypeScript takes a bare value as `$eq`; Go spells the same comparison out as a `CondOp`: `tabnas.CEq("strict")`.

Output: `depth=1 mode=strict taken · depth=1 mode=loose skipped · depth=0 mode=strict skipped`

### The two halves behave differently

This is worth pinning down, because the asymmetry is invisible in the syntax:

**The asymmetry, measured**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

let mark = ''

// One alternate gated on the condition, one unconditional behind it. Nothing
// has set `u.flag`, `n.never` or `u.never`, so each row is the unset case.
function check(cond: any) {
  const tn = new Tabnas()
  tn.grammar({
    ref: {
      '@taken': () => { mark = 'taken' },
      '@skipped': () => { mark = 'skipped' },
    },
    options: { rule: { start: 'val' } },
    rule: {
      val: {
        open: [
          { s: '#NR', c: cond, a: '@taken' },
          { s: '#NR', a: '@skipped' },
        ],
        close: [{}],
      },
    },
  } as any)
  tn.parse('1')
  return mark
}

console.log("{ 'u.flag': 1 }              ", check({ 'u.flag': 1 }))
console.log("{ 'n.never': { $gte: 99 } }  ", check({ 'n.never': { $gte: 99 } }))
console.log("{ 'n.never': { $lt: 1 } }    ", check({ 'n.never': { $lt: 1 } }))
console.log("{ 'u.never': { $gte: 99 } }  ", check({ 'u.never': { $gte: 99 } }))
```

Go:

```go
package main

import (
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

var mark string

// One alternate gated on the condition, one unconditional behind it. Nothing
// has set `u.flag`, `n.never` or `u.never`, so each row is the unset case.
func check(cond map[string]any) string {
	j := tabnas.Make()
	err := j.Grammar(&tabnas.GrammarSpec{
		Ref: map[tabnas.FuncRef]any{
			"@taken":   tabnas.AltAction(func(r *tabnas.Rule, ctx *tabnas.Context) { mark = "taken" }),
			"@skipped": tabnas.AltAction(func(r *tabnas.Rule, ctx *tabnas.Context) { mark = "skipped" }),
		},
		OptionsMap: map[string]any{"rule": map[string]any{"start": "val"}},
		Rule: map[string]*tabnas.GrammarRuleSpec{
			"val": {
				Open: []*tabnas.GrammarAltSpec{
					{S: "#NR", C: cond, A: "@taken"},
					{S: "#NR", A: "@skipped"},
				},
				Close: []*tabnas.GrammarAltSpec{{}},
			},
		},
	})
	if err != nil {
		panic(err)
	}
	if _, err := j.Parse("1"); err != nil {
		panic(err)
	}
	return mark
}

func main() {
	fmt.Println("{ 'u.flag': 1 }              ", check(map[string]any{"u.flag": 1}))
	fmt.Println("{ 'n.never': { $gte: 99 } }  ", check(map[string]any{"n.never": tabnas.CGte(99)}))
	fmt.Println("{ 'n.never': { $lt: 1 } }    ", check(map[string]any{"n.never": tabnas.CLt(1)}))
	fmt.Println("{ 'u.never': { $gte: 99 } }  ", check(map[string]any{"u.never": tabnas.CGte(99)}))
}
```

Explain: The asymmetry is invisible in the syntax, so here it is measured. `$eq` on a path that does not resolve fails closed. A counter compares as a number from zero, so `$gte: 99` is a real comparison that fails and `$lt: 1` is one that passes. An ordered op on a **non**\-counter path that does not resolve fails open. Use `$exist` when you mean "only if this was explicitly set". TypeScript writes the operators as an object; Go passes the same comparison as a `CondOp` value (`tabnas.CGte(99)`) which is data, not a closure, so the grammar still serialises.

Output: `{ 'u.flag': 1 } skipped · { 'n.never': { $gte: 99 } } skipped · { 'n.never': { $lt: 1 } } taken · { 'u.never': { $gte: 99 } } taken`

So counters compare as numbers from zero, `$eq` on a non-counter path fails closed, and the ordered ops fail open only for paths that are not counters. Use `$exist` when you mean “only if this was explicitly set”: on a counter that is the sole way to tell “never counted” from “counted zero”.

## Which form to use

| If you want | Use |
| --- | --- |
| A grammar that stays serialisable | the object form |
| A grammar an agent emitted, checkable before running | the object form |
| Anything the object form can’t express | a function |

The object form covers comparisons against rule state, which is most guards. Anything involving two paths, arithmetic, or a call into your own code needs a function, and the grammar becomes code at that point.

## Next

-   [The rule instance](https://tabnas.dev/docs/rule-instance/): every path the object form can reach.
-   [Choose between alternates](https://tabnas.dev/how-to/choose-between-alternates/): order, lookahead and group tags, the other ways to pick a branch.
-   [Give good parse errors](https://tabnas.dev/how-to/parse-errors/): the `e` alternate the guard above uses.
