060. Value Types and Generic Nodes
- Status: Implemented
- Author: @ornew
- Date: 2026-10-17
Summary
Revise the language incompatibly around one idea: a rule produces an ordinary
typed value, and the concrete syntax tree is built from a single generic
node[T, C] type whose Value is that value. The reserved node types Match,
Seq, List, Operator and Error, the terminal type and node fields
disappear. Types gain tuples, primitive lists, a top type _, a bottom type
_|_, joins, meets and an explicit distinction between type definitions and
type aliases. Actions lose positional captures, keep text for the input a
node spans, and gain indexing and the .$ capture-access operator.
In this record, standard syntax is the syntax users write; extended syntax
is notation the implementation uses to describe built-in types that standard
syntax cannot define, such as node[T, C].
Motivation
- Grammar results are untyped trees of reserved node kinds. A grammar that wants
an
intor a primitive list cannot return one, and rules must return nodes. - Captures are stored as node fields, so a capture of a subtree places that subtree in the tree twice.
- Positional captures (
$1,$n,$0) are hard to count and break when a rule changes. type T = Umeans both "alias" and, with a struct right-hand side, nothing else; aliases preserve identity in some cases and not in others.
Types
Built-In Types
| Type | Written | Values |
|---|---|---|
int |
int |
0, -1, 100 |
string |
string |
"..." |
bool |
bool |
true, false |
| join | T | U |
a value of one of its arguments |
| list | []T |
[]int{1, 2, 3} |
| tuple | (), (T,), (T, U) |
(1, Foo{}, []int{}) |
| optional | *T |
nil or a value of T |
| struct | {...} |
T{...} of a defined struct type |
| error | error |
produced only by error recovery |
| top | _ |
every value |
| bottom | _|_ |
no value |
| node | node (extended: node[T, C]) |
parse results |
join[...], list[T], tuple[...] and optional[T] are the internal names
of the higher-kinded constructors. Standard syntax writes them only with their
operators, and diagnostics print the operator form.
Join. | is associative and commutative and is normalized to a flat set:
T | (U | V) is T | U | V, T | U is U | T, and duplicates collapse.
T | U is the join (least upper bound), so A | B is B when A <= B, and
int | int is int. A join with one argument is that argument; one with no
arguments, which only computation can produce, is _|_.
Tuple. () is the unit. A trailing comma is optional except in a
one-element tuple, where (T) is a group: (T,) is required. Tuples are
neither associative nor commutative: ((T, U), V) differs from
(T, (U, V)), and (T, U) from (U, T).
List and tuple indexing. x[i] reads the element at zero-based index i.
A node is indexed through its value: when x is a node, x[i] is
x.Value[i], so $x[1] reads the second element of a captured sequence. A
constant index outside a tuple is a compile-time error.
Optional. Untyped nil is assignable to every optional type.
Top _. The join of all types: _ | T = _ and _ & T = T. A _ value is
not assignable to a concrete type; that is a compile-time error unless an
explicit cast is written, which is checked at run time and can fail there.
Bottom _|_. The meet of all types: _|_ | T = T and _|_ & T = _|_.
Any other constructor applied to _|_ is _|_ ([]_|_, *_|_). A variable,
field or other location whose type resolves to _|_ is a type error.
Meet &. T & U is the greatest lower bound. With the current types every
meet of distinct types is _|_ (for example int & string), so the operator
is defined but has no practical use yet.
error. Conceptually {Start int; End int; Msg string; ...}, but it carries
runtime and source-map information that standard syntax cannot define. Only
error recovery creates error values; no syntax constructs or handles them, so
they appear only in parse results.
Generic Nodes
// extended syntax
type node[T _, C :map[:string, :int]] = {Start int; End int; Value T; Source *SourceInfo #runtime}
node replaces every reserved CST type. Value has the type argument T.
A Type Expression always gives the argument: node[_] is a node of any value,
and a bare node is an error, so that every field and rule type states what
its node holds. A rule's declared type is its type everywhere, node[_] included:
a rule declared node[_] has the type node[_] and not the type of its value.
C is static type-level information mapping capture names to element indexes,
for example node[T, {"name": 1, "foo": 3}]; its map, strings and integers are
types, not values. The checker uses C to resolve a capture access to an index
at compile time. At run time C exists only inside opt-in source information.
Source is runtime-only and is not accessible from actions. Standard syntax
cannot construct a node value.
The examples below omit C.
| Rule | Result type |
|---|---|
rule a = "x" |
node[string] |
rule a = ("x" "y"*) #atomic |
node[string] |
rule a = b c d |
node[(B, C, D)], with B, C, D the results of the rules |
rule a = b+ |
node[[]B] |
rule a = "a"? |
*node[string] |
rule a = b? c |
node[(*B, C)] |
rule a = _ => Foo{} |
Foo |
rule b = a "a"* |
node[(Foo, node[[]node[string]])] |
rule a = _ => 100 |
int |
A rule without an action is an implicit node rule; rule a node = ... states
it explicitly and still infers the type arguments. Rules may now produce any
value, so arbitrary ASTs can be built. Because captures are no longer fields,
a captured subtree appears in the tree once. Source information, which is opt-in,
holds the producing rule of each node and the capture names with their
locations; it does not record an operator's binding level.
Type Definitions and Aliases
type T = {...} is a type definition: it creates a new named type whose
underlying type is the right-hand side. Only struct literals are allowed today;
type Int = int parses but is a semantic error. type T ~ U is an alias: T
is only another name that resolves to U and creates no type.
type T = {} // a new type, distinct from {} and from any other definition
type T ~ {} // another name for the unnamed type {}
Users can define only struct types; every other type is built in or built from
others with type operators: |, &, (,), [], *, {...} and type
application T[U, V] (as in node[string]).
Structural equivalence and identity are separate. Types are compared after
normalization. Two struct types are structurally equivalent when they have the
same field names with identical types, in any order. Two types are identical
when their underlying types are structurally equivalent and either both come
from the same type definition or both are unnamed. With type T = {} and
type U = {}, T, U and {} are structurally equivalent and pairwise not
identical; with type T ~ U and type V ~ U, T, U and V are identical.
Rule Expressions
| Operator | Name | Value type | Consumes input |
|---|---|---|---|
"..." |
literal | string |
yes |
[...] |
character class | string |
yes |
. |
any character | string |
yes |
a |
rule call | the rule's type | as the rule |
f(...) |
rule function call | the rule's type | as the rule |
_ |
top | none | no |
_|_ |
bottom | none | no |
^^, $$, ^, $ |
anchors | none | no |
a b ... |
sequence | (A, B, ...) |
as the elements |
a / b |
ordered choice | A | B |
as the alternatives |
(a) |
group | A |
as a |
a #atomic |
atomic | string |
as a |
a #repeat(...), a*, a+ |
repetition | []A |
as a |
a? |
optional | *A |
as a |
&a, !a |
lookahead | none | no |
-a |
discard | none | as a |
#cut |
cut | none | no |
n:a |
capture | A |
as a |
a @n(...) |
attribute | A |
as a |
#stream |
stream | none | no |
{...} |
Predicate Action | none | no |
a => x |
Value Action | X |
as a |
The cut becomes the #cut pragma; -- is removed. Sequence and choice values
are flat: a (b c) and (a b) c both have the value (A, B, C), because a
group creates no value, while a (b c => X{}) has (A, X). Choice likewise
produces one flat join.
A compile option selects CST mode: every action is replaced by the default,
and the parser returns the default node tree even for grammars with actions.
Actions
- Captures only by name.
$1, ...,$nand$0are removed. Captures resolve to indexes at compile time, so named access has no runtime lookup. - Lambda parameters are variables. Captures remain
$x; lambda parameters share the variable namespace and are written without$:{ indent := 1 } => { indent + 1; map($items, (x) => x.$item) }. - Node access. A captured node exposes
.Start,.Endand.Value;.Sourceis rejected.text(x)remains and returns the input that nodexspans as astring, whatever its value, soX{Text: text($x)}keeps its meaning; for a terminal,$x.Valueis the same string.len($x.Value)andlen($x)keep their meaning. .$capture access.i.$yreads captureyof nodei; its receiver must be anode, andCturns the name into an index. Forrule a = x:(y:.)* => map($x, (i) => i.$y),$x.Valuehas type[]node[..., {"y": 0}], soi.$yisi.Value[0].
Impact
The change touches the grammar parser and formatter, the checker, every
backend (Closure, VM bytecode and saved formats, generated Go Node and typed,
TypeScript), Documents and streams, error recovery and missing tokens, output
schemas and structural queries, JSON and S-expression output, the LSP, the
playground, examples, the ready-made parsers in parsers/, the specification
and every guide. Saved bytecode and schema formats need new versions.
Decisions
-
Call-only rules are transparent. An action-free rule whose body is a single rule call,
rule a = b, has the type ofbunchanged, so alias-like rules do not buildnode[node[...]]chains. -
Sequences are always tuples.
rule a = -ws b $$has typenode[(B,)]; adding or removing valueless elements does not change the shape. -
Operator lines produce tuple nodes. Without an action, an infix line produces
node[(L, Op, R)], a prefix linenode[(Op, R)]and a postfix linenode[(L, Op)], withCmappinglhs,opandrhs. Actions keep using$lhs,$opand$rhs. -
Recovery is an explicit join.
a #recover(...)and a literal with#missinghave typeA | error. Missing tokens becomeerrorvalues that record the expected token at run time.erroris not implicitly assignable to other types. -
Valued subexpressions are nodes. Inside an action-free rule, literals, character classes,
.,#atomicand repetitions each produce anodewith its own range; rule calls keep the called rule's value, and groups and sequences flatten. A rule whose top-level expression already produces a node uses that node as its value without wrapping it again. -
Captures in choices share the flat element. In
(x:a / y:b) c, the choice occupies one tuple element and bothxandyindex it. A capture whose alternative did not match isnil, so$xhas type*Aand$ytype*B; the runtime records which alternative matched. -
A capture has a location per alternative. Alternatives may capture the same name at different positions: in
(y:b c / y:d) e,yis the first element of a nested tuple in one alternative and the whole element in the other, andCmapsyto both. The runtime selects the location from the alternative that matched, which it records anyway, so only reads of such a capture pay for a branch. The repository grammars use this in dozens of places, for examplews n:NOT? ws t:(NULL / TRUE) / t:ISNULL, and a choice of a one-element sequence and a bare element,&[0-9] v:int / v:paren. -
Captures in positive lookaheads are kept beside the tuple. A capture in
&ahas no element, since the lookahead consumes nothing, but its value remains in effect after the lookahead, so actions, predicates and bindings read it.Crecords that its value is beside the tuple, and an alternative may capture a name in a lookahead while another captures it at a position, as in!nl -ws e:expr / &nl &(-ws e:missing), which places a zero-width node after the whitespace that the statement does not consume. -
Casts are written
x.(T). The postfix form chains with field access and fails at run time with an action error when the value is not aT. -
Migration replaces the old syntax at once. No compatibility mode is kept. A
pego fixrewrite handles the mechanical parts (struct definitions,--to#cut, positional to named captures, lambda parameters), and the repository grammars andparsers/are migrated with it. -
Terminal types become one-field structs. A terminal type names the kind of a token, and joins of them, such as
Number | String | Bool, tell the kinds apart, whichnode[string]cannot do.pego fixrewritestype T terminaltotype T = { Text string }, a rulerule r T = ewithout an action torule r T = t:(e) #atomic => T{Text: text($t)}(without#atomicwhenehas captures, which predicates may read), and a positional constructorT{s}toT{Text: s}. The struct keeps the range of the rule, and a generated Go*Tkeeps itsSpanandText. The reserved names are rewritten to the value types they stand for:Matchandterminaltonode[string],Seq,ListandOperatortonode, andErrortoerror. -
Subtyping is minimal.
_|_ <= T <= _,T <= *T,nil <= *T, and each join argument is below its join.[]T,*Tandnode[T, C]are covariant inT, and tuples are covariant element-wise. Named structs compare by identity only, without width subtyping. Nodes whoseCdiffer are distinct types, and their join keeps both. -
.$yrequires every alternative to havey. On a join of nodes,.$yis allowed only when every argument capturesy; its type is the join of their capture types, and differing indexes are selected at run time from the matched alternative. An optional receiver is a type error, as for field access. -
Cycles through actions are least fixed points. A rule type stays a reference until an action reads into it, so recursion without actions needs no iteration. When actions of rules that refer to each other read into each other's values, the rules of that strongly connected component start from
_|_and are computed again from the previous round's values until none changes; reading into_|_is not an error. The result and the errors do not depend on the order of the rules. Values that still change after a fixed number of rounds become unknown, as the value of a predicate variable is. -
Valueless bodies produce the unit node.
rule stub = _, and an action-free rule whose sequence has no valued element, producenode[()]. -
Output formats follow the value. In JSON a node is
{"start": S, "end": E, "value": V}, whereVis a string, an array for a tuple or a list,nullfor an optional that did not match, or the value of an action: an object for a struct, a number or a boolean. A tuple and a list are told apart by the schema. The S-expression output writes the value's shape without positions:( )for a tuple,[ ]for a list, quoted strings andnil. Each captured subtree appears once. Rule names and capture names are written only when the grammar is compiled with source information, as"rule"and"captures"members in JSON and as@ruleand aname:label before the element in S-expressions, for example(k:["a"]@key ":" v:["1"]@num)@main.pego parsedoes not request source information unless asked to. -
The Go node is opaque. The public
Nodeexposes methods only: its range, its value, the elements of a tuple or list value, and, with source information, its rule and captures. No field or method commits to a memory layout, so the engine may store trees in flat arrays or arenas and avoid allocating nodes for terminals. -
The engine moves at once on a branch. The repository grammars, the closure backend, the VM, Documents and snapshots, the output formats, schemas and queries, and generated code move in separate commits on one branch, which replaces the current model on the main line only when every backend agrees and every test and ready-made parser passes. No release carries both tree models.
-
Typos in the source.
a?has type*A, not[]A, and the tuple example is(T, U) != (U, T).
Open Questions
No open questions remain.
Alternatives Considered
- Keep reserved node kinds and add values beside them. This keeps compatibility but leaves two value models and the duplicated capture fields.
- Keep captures as fields. This keeps runtime name lookup and duplicates captured subtrees.
- Keep positional captures alongside names. This keeps the fragility the change removes.
- Give a capture one position. Rejecting a name captured at different
positions keeps
Ca map from a name to one index, but the repository grammars capture names at different positions in dozens of choices, and rewriting them needs distinct names and a way to choose between them in the action. - Reject reading captures in lookaheads from actions. Actions would need
the captured value through a predicate variable, which holds only
int,stringandbool, and the zero-width node placed after whitespace could not be expressed.
Testing and Performance Plan
Every backend must agree on values, types and diagnostics for the corpus rewritten to the new syntax. Compare tree size, allocation and parse time with the current representation on the benchmark grammars before and after the removal of capture fields; record whether compile-time capture indexes remove lookup costs as expected.
Performance and Results
Three alternating samples of BenchmarkParse, BenchmarkRecognize,
BenchmarkIncremental and BenchmarkStream compared the previous tree model
with the value model, each binary run against its own revision's grammars, on
an Apple M3 Max with Go 1.27.1. Over the 120 benchmarks the geometric means of
the median ratios were 1.013× time, 1.066× bytes and 1.048× allocations per
operation; the time spread between samples was mostly 0–3% and up to 8%.
- Every node is larger. A node now has room for a value built by an action, which accounts for the uniform 1.04–1.09× bytes on JSON, CSV, XML, Outline and streams, at 0.98–1.04× time.
- Grammars with many valued subexpressions build more nodes. Optionals,
repetitions and literals inside sequences are nodes of their own, and
operator lines build tuples. Minilang parses took 1.20–1.41× bytes and
1.03–1.08× time on the engine backends and 1.70× bytes on the generated
Nodeparser; operators-rule arithmetic took 1.27× bytes and recovery 1.21–1.67×. - Typed generated values are smaller. Without node fields, generated typed Minilang parses took 0.21× bytes and 0.84× time, and recovery 0.25× bytes and 0.83× time.
- Recognition is unchanged within 0.99–1.04× time; its allocation counts stay in the tens per parse.
- Incremental reparses took 1.04–1.06× time; fresh Documents took 1.22–1.41× bytes and Document edits 1.03×.
The node size and the extra nodes of valued subexpressions are the candidates
for later work: carrying action values outside the node, and not allocating
nodes for terminals, which the opaque Node permits.