This guide provides best practices for projects that extend Traqula's parsers, generators, or transformers. For a real-world example, see the internal SPARQL 1.2 parser, the SPARQL 1.2 + lateral parser, or the shacl-rules-parser, which extends Traqula's SPARQL 1.2 parser with custom grammar.
When defining grammar rules in TypeScript, always explicitly provide the type parameters. This ensures the builders know the exact name and return type of each rule at compile time, enabling compile-time validation against duplicate names and type mismatches.
import type { ParserRule } from '@traqula/core';
// ✅ Good: explicit type parameters
const myRule: ParserRule<MyContext, 'myRule', MyReturnType> = {
name: 'myRule',
impl: ({ CONSUME, ACTION }) => (C) => {
const token = CONSUME(MyToken);
return ACTION(() => ({ /* ... */ }));
},
};
// ❌ Bad: no type annotation — TypeScript infers broader types
const myRule = {
name: 'myRule',
impl: ({ CONSUME, ACTION }) => (C) => {
const token = CONSUME(MyToken);
return ACTION(() => ({ /* ... */ }));
},
};
The same applies to GeneratorRule and IndirDef:
import type { GeneratorRule, IndirDef } from '@traqula/core';
const myGenRule: GeneratorRule<MyContext, 'myRule', MyAstType> = {
name: 'myRule',
gImpl: ({ PRINT, SUBRULE }) => (ast, context) => {
PRINT(ast.value);
},
};
const myIndirDef: IndirDef<MyContext, 'myFunction', number, [number]> = {
name: 'myFunction',
fun: ({ SUBRULE }) => (context, input) => input + 1,
};
addMany for Batch Rule RegistrationWhen adding multiple rules to a builder, prefer addMany() over chaining individual addRule() calls.
The addMany() method uses rest parameters (...rules), which provides better TypeScript type inference
than the sequential type narrowing that happens with chained calls.
// ✅ Preferred: addMany for batches
const builder = ParserBuilder
.create(existingBuilder)
.addMany(ruleA, ruleB, ruleC, ruleD);
// ⚠️ Acceptable for small additions, but can cause excessive type inference with many rules
const builder = ParserBuilder
.create(existingBuilder
.addRule(ruleA)
.addRule(ruleB)
.addRule(ruleC)
.addRule(ruleD);
Do not pass too many rules to a single .create() or .addMany() call.
When the number of rules grows large,
TypeScript's type checker may struggle with the deeply nested mapped types,
producing cryptic errors similar to those you'd see with duplicate rule names.
In such cases, split into multiple .addMany() calls.
The same guidance applies to GeneratorBuilder.addMany() and IndirBuilder.addMany().
Traqula's builders (ParserBuilder, GeneratorBuilder, IndirBuilder, LexerBuilder) are mutable —
methods like addRule(), patchRule(), and deleteRule() modify the builder in place.
To avoid corrupting an upstream builder that other consumers depend on,
always start by creating a copy:
// ✅ Good: copy first
const myBuilder = ParserBuilder.create(sparql12ParserBuilder)
.addRule(myNewRule);
// ❌ Bad: mutates the shared builder
sparql12ParserBuilder.addRule(myNewRule);
The same applies to LexerBuilder.create(existingLexerBuilder),
GeneratorBuilder.create(existingGeneratorBuilder), and IndirBuilder.create(existingIndirBuilder).
patchRule, typePatch, and deleteRuleThese three methods serve different purposes when modifying an inherited grammar:
| Method | When to use |
|---|---|
patchRule(rule) |
Replace the implementation of an existing rule. The new rule must have the same name. |
typePatch<{...}>() |
Update only the type signature of rules whose return types or parameters changed due to upstream patches. No implementation change needed. |
deleteRule(name) |
Remove a rule entirely. Use when a patched grammar makes certain parent rules unreachable. |
Example workflow when restricting a grammar:
const myBuilder = ParserBuilder.create(sparql12ParserBuilder)
// Replace the implementation of 'path' with a restricted version
.patchRule(restrictedPath)
// 'pathAlternative' is no longer reachable with our restricted path
.deleteRule('pathAlternative')
// 'someRule' still has the same impl, but its return type changed
// because the patched 'path' now returns a narrower type
.typePatch<{
someRule: [NewReturnType],
}>();
Chevrotain requires unique occurrence indices when the same DSL method is used multiple times
within a single rule. This applies to CONSUME, SUBRULE, OR, OPTION, MANY, AT_LEAST_ONE,
and their _SEP variants.
const myRule: ParserRule<Ctx, 'myRule', Result> = {
name: 'myRule',
impl: ({ CONSUME, CONSUME2, SUBRULE, SUBRULE2, OR }) => (C) => {
// First use of CONSUME and SUBRULE — no suffix needed
const first = CONSUME(LParen);
const child1 = SUBRULE(someRule);
// Second use — use the "2" suffix
CONSUME2(RParen);
const child2 = SUBRULE2(someRule);
// ...
},
};
The numbering is per-rule and per-DSL-method. CONSUME and SUBRULE each have their own counter.
Suffixes go from 1 (or no suffix) to 9.
Use getRule() to retrieve a rule from an existing builder for inspection or wrapping:
// Access by string literal (type-checked against builder's known rules)
const originalRule = sparql12ParserBuilder.getRule('graphPatternNotTriples');
// Access using the rule's name property (equivalent, useful for refactoring safety)
const originalRule = sparql12ParserBuilder.getRule(someImportedRule.name);
A common pattern is wrapping a parent rule to add alternatives:
const originalGraphPattern = existingBuilder.getRule('graphPatternNotTriples');
const extendedGraphPattern: typeof originalGraphPattern = <const> {
name: originalGraphPattern.name,
impl: ($) => (C) => $.OR([
{ ALT: () => $.SUBRULE(myNewPatternRule) },
{ ALT: () => originalGraphPattern.impl($)(C) },
]),
};
When building a parser, you can provide a custom errorHandler to control how parse errors are reported:
const parser = myParserBuilder.build({
tokenVocabulary: myLexerBuilder.tokenVocabulary,
errorHandler: (errors) => {
const firstError = errors[0];
throw new Error(`Parse error at line ${firstError.token.startLine}: ${firstError.message}`);
},
});
If no errorHandler is provided, the builder uses a default handler that throws on the first error.
When positionTracking is set to 'full' in the lexer configuration, the default handler formats the error
with line information and a caret pointing to the error position.
When publishing your extended parser/generator, export the builders, individual grammar rules, and token definitions — not just the final built parser. This allows downstream projects to further extend your grammar:
// ✅ Good: export building blocks
export { myLexerBuilder } from './lexer.js';
export { myParserBuilder } from './parser.js';
export { myNewRule, myOtherRule } from './grammar/rules.js';
export { MyKeyword, MyToken } from './tokens.js';
export { MyParser } from './MyParser.js'; // The convenience wrapper
// ❌ Bad: only export the final parser
export { MyParser } from './MyParser.js';
When developing a modified parser or generator:
Disable skipValidations during development. Chevrotain validates your grammar for ambiguities
and unreachable alternatives. Only enable skipValidations: true in production once the grammar is stable.
Test round-tripping by parsing a query and regenerating it:
const ast = parser.parse(query);
const regenerated = generator.generate(ast);
expect(regenerated).toBe(query);
Reuse parser instances. Building a parser is expensive due to Chevrotain's grammar recording. Create the parser once and reuse it across test cases.
IndirBuilder (indirection builder) lets you compose transformation logic
the same way ParserBuilder and GeneratorBuilder compose grammar rules.
Use it when defining custom AST-to-algebra transformations.
import { IndirBuilder, type IndirDef } from '@traqula/core';
import { toAlgebra12Builder } from '@traqula/algebra-sparql-1-2';
// Define a custom transformation
const myTransform: IndirDef<MyCtx, 'myTransform', OutputType, [InputType]> = {
name: 'myTransform',
fun: ({ SUBRULE }) => (context, input) => {
// Transform input to output
return transformedResult;
},
};
// Copy and extend
const myAlgebraBuilder = IndirBuilder.create(toAlgebra12Builder)
.addRule(myTransform);
Use merge() to combine independent transformation sets:
const combined = IndirBuilder.create(builderA)
.merge(builderB, { onConflict: 'override' });
The onConflict parameter controls what happens when both builders define the same rule:
'override' — the merged-in builder's rule takes precedence'throw' — an error is thrown on conflict (default behavior)If both builders share the exact same reference for a rule, the merge proceeds without conflict.
For more details on creating transformers, see create a transformer and modify a transformer.
'myCustomRule', 'shaclRuleBlock').'MyKeyword', 'TransitiveKeyword').name field should be a specific string literal, not just string.