Wenn du jemals eine Yacc-Grammatikdatei geöffnet und dich gefragt hast, warum das Bauen einer Sprache das Erlernen einer zweiten Sprache erfordert, bist du nicht allein. Parser-Generatoren sind mächtig, aber für die kleinen DSLs, die natürlich innerhalb von Bounded Contexts entstehen, sind sie fast immer overkill.

Du kannst einen Parser in derselben Sprache wie den Rest deiner Anwendung schreiben. Keine Build-Schritte, keine Grammatikdateien, kein generierter Code, den du nicht debuggen kannst.

Parser-Generatoren lösen ein Problem, das du wahrscheinlich nicht hast

Parser-Generatoren wie ANTLR, Bison oder PEG.js zwingen dich, deine Grammatik in einer domänenspezifischen Metasprache auszudrücken. Du schreibst eine .g4- oder .y-Datei, führst ein Tool aus, bekommst generierten Quellcode, importierst ihn und hoffst, dass die Fehlermeldungen Sinn ergeben, wenn etwas kaputtgeht.

Für eine vollständige Programmiersprache lohnt sich dieser Trade-off. Generierte LR-Parser sind schnell, und die Trennung zwischen Grammatik und Implementierung ist sauber.

Aber die meisten von uns bauen keine Programmiersprachen. Wir bauen Query-Filter, Config-Formate, Rule Engines oder winzige Expression Languages, die in einem einzigen Bounded Context leben. Der Overhead eines Parser-Generators, ein separater Build-Schritt und eine zweite Syntax zum Lernen sind reine Reibung.

Parser-Kombinatoren machen aus Parsing normalen Code

Parser-Kombinatoren sind Funktionen, die Parser zurückgeben, und Parser sind Funktionen, die Input konsumieren und entweder einen geparsten Wert oder einen Fehler zurückgeben. Du komponierst kleine Parser zu größeren mithilfe von Higher-Order Functions.

Ein Parser für den String "hello" ist eine Funktion. Ein Parser für "hello" OR "world" ist eine Funktion, die den ersten versucht, und wenn er fehlschlägt, den zweiten. Ein Parser für "hello" THEN "world" führt sie sequenziell aus und kombiniert die Ergebnisse.

Das bedeutet, deine Grammatik ist einfach Code. Du debuggst ihn mit einem Breakpoint, nicht mit einem Grammatik-Visualisierungstool.

Ein funktionierender Arithmetik-Parser in 40 Zeilen TypeScript

Hier ist ein kompletter Parser für eine winzige Expression Language. Er verarbeitet Integers und geklammerte Addition. Keine Dependencies, keine generierten Dateien.

type Result<T> = 
  | { ok: true; value: T; pos: number }
  | { ok: false; error: string; pos: number };

type Parser<T> = (input: string, pos: number) => Result<T>;

// Primitives: match a literal string or a regex
const str = (expected: string): Parser<string> => (input, pos) => {
  const end = pos + expected.length;
  return input.slice(pos, end) === expected
    ? { ok: true, value: expected, pos: end }
    : { ok: false, error: `expected "${expected}"`, pos };
};

const regex = (re: RegExp): Parser<string> => (input, pos) => {
  const m = input.slice(pos).match(new RegExp(`^(?:${re.source})`));
  return m
    ? { ok: true, value: m[0], pos: pos + m[0].length }
    : { ok: false, error: `expected /${re.source}/`, pos };
};

// Combinators: sequence, choice, map, lazy
const map = <A, B>(p: Parser<A>, f: (a: A) => B): Parser<B> => (input, pos) => {
  const r = p(input, pos);
  return r.ok ? { ...r, value: f(r.value) } : r;
};

const seq = <T extends Parser<unknown>[]>(...ps: T): Parser<any[]> => (input, pos) => {
  const values: unknown[] = [];
  let curr = pos;
  for (const p of ps) {
    const r = p(input, curr);
    if (!r.ok) return r;
    values.push(r.value);
    curr = r.pos;
  }
  return { ok: true, value: values, pos: curr };
};

const or = <A, B>(a: Parser<A>, b: Parser<B>): Parser<A | B> => (input, pos) => {
  const r = a(input, pos);
  return r.ok ? r : b(input, pos);
};

const lazy = <T>(fn: () => Parser<T>): Parser<T> => (input, pos) => fn()(input, pos);

// Grammar: expr := number | "(" expr "+" expr ")"
const ws = regex(/\s*/);
const number = map(regex(/\d+/), n => parseInt(n, 10));

let expr: Parser<{ val: number }>;

expr = or(
  map(
    seq(str("("), ws, lazy(() => expr), ws, str("+"), ws, lazy(() => expr), str(")")),
    ([, , left, , , , right]) => ({ val: left.val + right.val })
  ),
  map(number, val => ({ val }))
);

// Run it
const result = expr("(1 + (2 + 3))", 0);
console.log(result.ok ? result.value : result.error);
// Output: { val: 6 }

Lass uns aufschlüsseln, was tatsächlich passiert. str, regex und number sind primitive Parser. seq führt Parser in Reihenfolge aus. or probiert Alternativen. map transformiert das Ergebnis. lazy ist das einzige subtile Stück. Es verschiebt die Auswertung, damit rekursive Grammatiken zur Definitionszeit nicht explodieren.

Das Type-System trackt, was jeder Parser produziert. Wenn das Parsing fehlschlägt, bekommst du die exakte Position und das erwartete Token. Das ist bereits besseres Error Reporting als die meisten generierten Parser out of the box bieten.

Warum das besonders gut zu Bounded Contexts passt

Bounded Contexts in Domain-Driven Design sind absichtlich klein. Eine DSL, die in einem lebt, sollte ebenfalls klein sein. Sie braucht genau die Konstrukte, um die sich dieser Context kümmert, und nichts anderes.

Parser-Kombinatoren skalieren wunderschön nach unten. Du schreibst den Parser für den einen Query-Filter, den deine Domain braucht, nicht für eine General-Purpose-Query-Language. Du fügst ein neues Konstrukt hinzu, indem du eine neue Funktion schreibst, nicht indem du tausend Zeilen C neu generierst.

Der Parser lebt im selben Repository, in derselben Sprache und im selben mentalen Modell wie der Rest deines Bounded Context. Wenn sich das Domain-Model ändert, ändert sich der Parser mit. Es gibt keine Grammatikdatei, die in einem anderen Verzeichnis aus dem Takt driftet.

Wo Kombinatoren an ihre Grenzen stoßen

Sie sind nicht umsonst. Recursive-Descent-Parser, die Kombinatoren unter der Haube bauen, haben Probleme mit linksrekursiven Regeln. Wenn du expr := expr + number | number schreibst, ruft sich der Parser ewig selbst auf.

Du behebst das, indem du linksrekursive Regeln in Schleifen umschreibst oder indem du eine Library verwendest, die Left Recursion für dich handhabt. Für kleine DSLs ist das selten ein praktisches Problem.

Performance ist das andere Caveat. Ein handgetuneter Recursive-Descent-Parser oder ein generierter LR-Parser wird Kombinatoren beim reinen Durchsatz schlagen. Für eine Config-Datei, die einmal beim Start gelesen wird, oder eine Rule, die pro Request evaluiert wird, ist der Unterschied Mikrosekunden. Messe, bevor du annimmst, dass es relevant ist.

Wie du anfängst, deinen eigenen zu bauen

Starte mit der kleinstmöglichen Grammatik. Parse ein Konstrukt, teste es, dann komponiere.

Wenn du TypeScript schreibst, geben dir Libraries wie parsimmon, arcsecond oder chevrotain produktionsreife Kombinatoren mit besseren Fehlermeldungen und Left-Recursion-Handling als die Scratch-Implementierung oben. Rust hat nom. Haskell hat Parsec. Python hat parsy.

Das Pattern ist überall dasselbe: Primitives, Sequence, Choice, Repetition, Transformation. Lerne es einmal, wende es in jeder Sprache an.

FAQ

Was ist ein Parser-Kombinator?

Ein Parser-Kombinator ist eine Higher-Order Function, die einen oder mehrere Parser nimmt und einen neuen Parser zurückgibt. Sie erlauben es dir, komplexe Parser durch Komposition einfacher Parser zu bauen, mit normalem Code statt einer separaten Grammatiksprache.

Wann sollte ich trotzdem einen Parser-Generator verwenden?

Greife zu einem Generator, wenn du eine vollständige Programmiersprache parst, wenn du maximalen Parsing-Durchsatz brauchst oder wenn dein Team bereits tiefe Expertise in einem bestimmten Generator-Ökosystem hat. Für kleine DSLs lohnt sich der Overhead meist nicht.

Sind Parser-Kombinatoren langsam?

Sie sind langsamer als handgeschriebene oder generierte LR-Parser, aber die Lücke ist für die meisten DSL-Anwendungsfälle irrelevant. Ein typischer Kombinator-Parser verarbeitet tausende Tokens pro Millisekunde. Das ist schnell genug für Config-Dateien, Query-Strings und Rule Engines.

Kann ich Parser-Kombinatoren auch in anderen Sprachen als TypeScript verwenden?

Absolut. Das Pattern ist sprachunabhängig. nom in Rust, Parsec in Haskell, parsy in Python und attoparsec in Haskell sind alle ausgereifte, weit verbreitete Libraries.

Das nächste Mal, wenn du eine winzige Sprache innerhalb eines Bounded Context brauchst, frag dich, ob du wirklich eine Grammatikdatei und einen Code-Generation-Schritt brauchst. Für die meisten DSLs ist die Antwort nein. Einhundert Zeilen Kombinator-Code geben dir einen Parser, den du im Debugger durchsteppen kannst, ohne etwas neu zu generieren erweitern kannst und sechs Monate später tatsächlich verstehst.