Skip to content

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 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 identifiersnullable 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 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 a constVal like 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-1 parses as (5-2)-1 — for -, /, and friends.
    • A rest() tail recurses right: 2^3^2 parses as 2^(3^2) — for exponentiation and ternaries.

    All patterns in one precedence level must agree on their tail shape; mixing them is a construction error.

// operand() tail → LEFT-associative: a-b-c = (a-b)-c
const 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:

  1. 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.
  2. lhs/rhs named 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”) and rest() (“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.

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.

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.

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(...), and rest/expr at position 0 (guaranteed infinite recursion)
  • leaf-level patterns starting with expression elements, constVal(""), and undelimited expr() (no following constVal)
  • 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 in resultType
  • missing resultType where 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.