Defining a Grammar
A grammar is a list of nodes, each declared with defineNode and combined
with createParser. A node is a pattern of elements, a precedence, and
(usually) a result type and an evaluation function:
const mul = defineNode({ name: "mul", precedence: 3, pattern: (p) => p .operand("number").as("left") .constVal("*") .operand("number").as("right") .result("number") .eval(({ left, right }) => left() * right()),});Pattern elements
Section titled “Pattern elements”| Pattern | Description |
|---|---|
number() |
Numeric literals |
string(quotes) |
Quoted strings, e.g. string(['"', "'"]) — escapes are processed (\n \t \\ \" \' \xHH \uHHHH, …) |
ident() |
Single identifier, type resolved from schema |
path() |
Identifier or dotted path (values.password), resolved via nested schema |
constVal(...values) |
Exact strings as an ordered alternation — first match wins (operators, keywords, delimiters). Identifier-like values match whole identifiers only |
operand(constraint?) |
Subexpression at the next tighter precedence level |
rest(constraint?) |
Subexpression at the current level |
expr(constraint?) |
Full expression — resets to the whole grammar (parens, ternary branches) |
Use .as(name) to capture an element as a named binding — bindings become
fields on the AST node and typed parameters of eval.
The roles name the parse level, not a left/right position: operand()
parses at the next tighter level (which is what prevents left recursion),
rest() stays at the current level, and expr() resets to the full grammar.
Keyword literals are ordinary const-pattern nodes — there is no special keyword element:
const nullLit = defineNode({ name: "null", precedence: 4, pattern: (p) => p.constVal("null").result("null").eval(() => null),});This works because identifier-like const values match as whole identifiers
— nullable is one identifier, never null + able (the word-boundary
rule; non-identifier values like "+" match as raw text). Within a level,
keyword-const nodes must come before ident()/path() nodes, or true
parses as an identifier. The \xHH/\uHHHH string escapes decode at runtime
only; in literal mode (parse/evaluate) they are rejected — use
safeParse for strings that need them.
Precedence & associativity
Section titled “Precedence & associativity”-
Precedence is a non-negative integer. Lower numbers bind looser (outermost in the tree); higher numbers bind tighter. The highest level present is the leaf level — its patterns must start with a consuming element (literals,
path(), or aconstVallike an opening paren). Duplicate precedences share a level; nodes within a level are tried in definition order with backtracking. -
Associativity is derived from the pattern’s tail element — there is no associativity property to set:
- An
operand()tail folds left:5-2-1parses as(5-2)-1— for-,/, and friends. - A
rest()tail recurses right:2^3^2parses as2^(3^2)— for exponentiation and ternaries.
All patterns in one precedence level must agree on their tail shape; mixing them is a construction error.
- An
// operand() tail → LEFT-associative: a-b-c = (a-b)-cconst sub = defineNode({ name: "sub", precedence: 2, pattern: (p) => p .operand("number").as("left") .constVal("-") .operand("number").as("right") .result("number") .eval(({ left, right }) => left() - right()),});
// rest() tail → RIGHT-associative: 2^3^2 = 2^(3^2)const pow = defineNode({ name: "pow", precedence: 3, pattern: (p) => p .operand("number").as("left") .constVal("^") .rest("number").as("right") .result("number") .eval(({ left, right }) => left() ** right()),});On left-associative levels the parser seeds an operand from the next level and
then iteratively folds op operand. The fold re-checks each candidate node’s
leading constraint against the folded-so-far result on every iteration, so
heterogeneous operators can share a level.
expr() must be followed by a constVal in the same pattern — it resets to
the full grammar, so it is only sound in delimiter-bounded regions (parens’
), ternary’s :). An undelimited expr() tail would swallow operators
looser than the node’s own precedence and break associativity; final operand
slots must be operand()/rest().
Why there is no associativity property (history)
Section titled “Why there is no associativity property (history)”Earlier versions of stringent had both an associativity property and
lhs()/rhs() element factories. That design had two defects, and the
current shape-derived rule is the fix for both:
- The property could lie. Associativity is not a free choice — it is a
consequence of which grammar level the tail slot parses at. A tail
that parses at the next tighter level can only fold left; a tail that
re-enters the current level can only recurse right. The old runtime
quietly reinterpreted a left level’s
rhs()tail to parse at the next level regardless of the declared property, so a pattern’s label and its actual behavior could disagree, and the property read as configuration while actually being (ignored) documentation. lhs/rhsnamed the wrong thing. They read as left/right position, but what they encoded was the parse level — tighter versus same.operand()(“parse a tighter-level operand”) andrest()(“parse the rest of this level”) say what they do, and the level is exactly the property associativity derives from.
Deriving associativity from the tail makes the pattern the single source
of truth — the same three tokens with a different tail role are different
math, which is the demonstration (pinned in design-claims.test.ts
“associativity by tail shape”):
// sub with an operand() tail folds left:leftParser.evaluate("10-5-2", {}, {}); // 3 — (10-5)-2// an otherwise-identical sub with a rest() tail recurses right:rightParser.evaluate("10-5-2", {}, {}); // 7 — 10-(5-2)Because the shape now is the semantics, a level whose patterns disagree on tail shape has no coherent reading — which is why mixing them became a construction error.
Result types
Section titled “Result types”resultType is any arktype definition — or the name of an earlier binding:
| Form | Meaning |
|---|---|
resultType: "boolean" |
static — the node mints a type (any arktype def, string or object) |
resultType: "then" |
derived — the node’s type is whatever the operand bound as then parsed as |
| omitted | only for passthrough patterns (a single unnamed non-const element) |
resultType is only required where it cannot be derived: single-element
passthrough atoms must omit it entirely (they forward a child and construct
nothing — declaring a type there would be a lie), binding references handle
polymorphic operators,
and a static definition is for nodes that mint a new type (like an eq node
producing "boolean"). eval’s return type is checked against the declared
resultType at the defineNode call site.
Evaluation is lazy
Section titled “Evaluation is lazy”eval receives each binding as a memoized thunk (() => value) — call a
binding to evaluate it. Untaken branches never run, which is how ternaries
and short-circuiting &&/|| work with no opt-in:
const ternary = defineNode({ name: "ternary", precedence: 0, pattern: (p) => p .operand("boolean").as("cond") .constVal("?") .expr().as("then") .constVal(":") .rest("then").as("else") .result("then") .eval(({ cond, then, else: alt }) => (cond() ? then() : alt())),});Memoization means a binding evaluates at most once, no matter how many times its thunk is called.
Construction-time validation
Section titled “Construction-time validation”createParser validates the whole grammar up front and throws descriptive
errors for:
- duplicate or reserved node names (
literal,identifier,path,const) - invalid precedence values and mixed tail shapes within a level
- left-level patterns not starting with
operand(...), andrest/exprat position 0 (guaranteed infinite recursion) - leaf-level patterns starting with expression elements,
constVal(""), and undelimitedexpr()(no followingconstVal) - binding names that collide with AST structure (
node,outputSchema,__proto__), repeat within a pattern, or shadow a resolvable type in scope - binding references to missing, later, or const bindings — in constraints
(
operand("left"),overlapping("left")) and inresultType - missing
resultTypewhere one is required (or one present on a passthrough pattern) - constraints and resultTypes that don’t resolve as
arktype definitions in the parser’s scope
(a typo’d
operand("numbr")throws immediately), and unsatisfiable constraint intersections
A grammar that constructs is a grammar that parses safely.