If you have ever opened a Yacc grammar file and wondered why building a language requires learning a second language, you are not alone. Parser generators are powerful, but for the small DSLs that naturally emerge inside bounded contexts, they are almost always overkill.
You can write a parser in the same language as the rest of your application. No build steps, no grammar files, no generated code you cannot debug.
Parser generators solve a problem you probably do not have
Parser generators like ANTLR, Bison, or PEG.js force you to express your grammar in a domain-specific metalanguage. You write a .g4 or .y file, run a tool, get generated source, import it, and hope the error messages make sense when something breaks.
For a full programming language, this trade-off is worth it. Generated LR parsers are fast, and the separation between grammar and implementation is clean.
But most of us are not building programming languages. We are building query filters, config formats, rule engines, or tiny expression languages that live inside a single bounded context. The overhead of a parser generator, a separate build step, and a second syntax to learn is pure friction.
Parser combinators turn parsing into ordinary code
Parser combinators are functions that return parsers, and parsers are functions that consume input and return either a parsed value or an error. You compose small parsers into larger ones using higher-order functions.
A parser for the string "hello" is a function. A parser for "hello" OR "world" is a function that tries the first, and if it fails, tries the second. A parser for "hello" THEN "world" runs them in sequence and combines the results.
This means your grammar is just code. You debug it with a breakpoint, not a grammar visualization tool.
A working arithmetic parser in 40 lines of TypeScript
Here is a complete parser for a tiny expression language. It handles integers and parenthesized addition. No dependencies, no generated files.
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 }
Let us break down what actually happens. str, regex, and number are primitive parsers. seq runs parsers in order. or tries alternatives. map transforms the result. lazy is the only subtle piece. It defers evaluation so recursive grammars do not explode at definition time.
The type system tracks what each parser produces. When parsing fails, you get the exact position and expected token. That is already better error reporting than most generated parsers give you out of the box.
Why this fits bounded contexts particularly well
Bounded contexts in Domain-Driven Design are intentionally small. A DSL that lives inside one should be small too. It needs exactly the constructs that context cares about, and nothing else.
Parser combinators scale down beautifully. You write the parser for the one query filter your domain needs, not for a general-purpose query language. You add a new construct by adding a new function, not by regenerating a thousand lines of C.
The parser lives in the same repository, the same language, and the same mental model as the rest of your bounded context. When the domain model changes, the parser changes with it. There is no grammar file drifting out of sync in another directory.
Where combinators fall short
They are not free. Recursive descent parsers, which is what combinators build under the hood, struggle with left-recursive rules. If you write expr := expr + number | number, the parser calls itself forever.
You fix this by rewriting left-recursive rules into loops, or by using a library that handles left recursion for you. For small DSLs, this is rarely a practical problem.
Performance is the other caveat. A hand-tuned recursive descent parser or a generated LR parser will beat combinators on raw throughput. For a config file read once at startup, or a rule evaluated per request, the difference is microseconds. Measure before you assume it matters.
How to start building your own
Start with the smallest possible grammar. Parse one construct, test it, then compose.
If you are writing TypeScript, libraries like parsimmon, arcsecond, or chevrotain give you production-ready combinators with better error messages and left-recursion handling than the scratch implementation above. Rust has nom. Haskell has Parsec. Python has parsy.
The pattern is the same everywhere: primitives, sequence, choice, repetition, transformation. Learn it once, apply it in any language.
FAQ
What is a parser combinator?
A parser combinator is a higher-order function that takes one or more parsers and returns a new parser. They let you build complex parsers by composing simple ones, using ordinary code instead of a separate grammar language.
When should I still use a parser generator?
Reach for a generator when you are parsing a full programming language, when you need maximum parsing throughput, or when your team already has deep expertise in a specific generator ecosystem. For small DSLs, it is usually not worth the overhead.
Are parser combinators slow?
They are slower than hand-written or generated LR parsers, but the gap is irrelevant for most DSL use cases. A typical combinator parser handles thousands of tokens per millisecond. That is fast enough for config files, query strings, and rule engines.
Can I use parser combinators in languages other than TypeScript?
Absolutely. The pattern is language-agnostic. nom in Rust, Parsec in Haskell, parsy in Python, and attoparsec in Haskell are all mature, widely used libraries.
The next time you need a tiny language inside a bounded context, ask yourself whether you really need a grammar file and a code generation step. For most DSLs, the answer is no. A hundred lines of combinator code will get you a parser you can step through in a debugger, extend without regenerating anything, and actually understand six months later.