⏱️ Reading time: 13 min
A regex to split date, time, and time zone works fine until a format you didn’t anticipate shows up, and then you have to rewrite the whole expression from scratch. Parser combinators solve that problem differently: instead of one giant expression, you compose small functions that pass the rest of the text to each other, like interlocking pieces.
📑 En este artículo
- TL;DR
- What Are Parser Combinators?
- Why Parser Combinators Matter
- How Combinable Parsers Work
- Practical Examples and Getting Started
- Real-World Use Cases
- Common Mistakes and Best Practices
- Comparison with Alternatives
- Going Deeper: Where This Idea Comes From
- Frequently Asked Questions
- Do Parser Combinators Replace a Formal Grammar Like BNF?
- What’s the Difference Between Combinable Parsers and Regular Expressions?
- Is Combinator-Based Parsing Slower Than a Generated Parser?
- Do I Need to Know Functional Programming to Use Parser Combinators?
- What Parser Combinator Libraries Exist for JavaScript or TypeScript?
- Are Combinable Parsers Useful for Full Language Compilers?
- References
TL;DR
- A parser is a function that takes text and returns the extracted value along with what’s left to read.
- pSeq, pMap, and pOr build complex parsers without writing a separate BNF grammar.
- TypeScript types each parser as Parser<T>, and the compiler flags it if you mix different types.
- Parsimmon applies this technique in JavaScript to parse JSON, dates, or custom configuration formats.
- pBind lets an already-read value decide how to parse what comes next, useful for variable-length fields.
What Are Parser Combinators?
Parser combinators is the name of a functional programming technique for building parsers by combining small, pure functions. Each function, called a parser, takes a string and returns the extracted value along with the unconsumed remainder. Combining them lets you build complex parsers without writing a separate formal grammar.
Think of it as an assembly line: each station takes the piece handed off by the previous one, does its specific job, and passes the rest to the next station. No station needs to know what the rest of the line does, so you can reorder, remove, or add stations without touching the others.
Why Parser Combinators Matter
There’s a huge gap between a one-line regex and a parser generator like ANTLR. The T3 blog frames it with two extremes. A regex becomes unreadable as soon as the format has nested structure, and tree-sitter, the library behind syntax highlighting in many code editors, is too heavy for a parser you’ll only use once. Parser combinators sit right in that middle ground.
The advantage shows up in everyday backend work: parsing a custom HTTP header, a log format, a small configuration language, or a simple binary protocol. None of those cases justify installing a grammar generator, but all of them turn fragile with a single giant regex nobody wants to touch afterward.
How Combinable Parsers Work
Here’s how parser combinators consume the input string step by step: each parser receives the full remaining string, keeps the portion it understands, and returns the rest for the next one. The type that describes this idea in TypeScript is compact.
type Parser<T> = (input: string) => [T, string];
A Parser<T> is a function from a string to a tuple with the value T and the remaining string. There’s no class, no shared state, no setup to initialize beforehand.
flowchart LR
A["Input text"] --> B["Parser 1 consumes one part"]
B --> C["Parser 2 consumes the next part"]
C --> D["Parser 3 consumes the rest"]
D --> E["Final result and empty string"]
The Smallest Possible Parser
The simplest parser recognizes digits and converts them into a number.
const pNumber: Parser<number> = (input) => {
const match = input.match(/^\d+/);
if (match === null) throw new Error("expected a number");
const digits = match[0];
return [Number(digits), input.slice(digits.length)];
};
console.log(pNumber("42 apples"));
The output is [ 42, ' apples' ]. The parser cut off the leading digits and returned the number along with everything that hasn’t been processed yet.
Sequences with pSeq
A single parser isn’t enough for anything real. pSeq chains several parsers together, passing the remainder of one as input to the next.
sequenceDiagram
participant P as pSeq
participant A as pVerbo
participant B as pEspacio
participant C as pNumber
P->>A: complete input
A-->>P: returns verb and remainder
P->>B: remainder after the verb
B-->>P: returns space and remainder
P->>C: remainder after the space
C-->>P: returns number and final remainder
Note over P: combines the three results into a tuple
const pLiteral = (texto: string): Parser<string> => (input) => {
if (!input.startsWith(texto)) throw new Error(`expected "${texto}"`);
return [texto, input.slice(texto.length)];
};
function pSeq<T extends unknown[]>(
...parsers: { [K in keyof T]: Parser<T[K]> }
): Parser<T> {
return (input) => {
const resultados: unknown[] = [];
let resto = input;
for (const parser of parsers) {
const [valor, nuevoResto] = parser(resto);
resultados.push(valor);
resto = nuevoResto;
}
return [resultados as T, resto];
};
}
const pMetodo = pLiteral("GET");
const pEspacio = pLiteral(" ");
const pLinea = pSeq(pMetodo, pEspacio, pNumber);
console.log(pLinea("GET 200 remaining"));
The output is [ [ 'GET', ' ', 200 ], ' remaining' ]. Each parser in the sequence kept its own portion, and the last one left the remainder untouched.
Transforming Results with pMap and Choosing with pOr
pMap applies a function to the value already extracted without touching the rest of the string, and pOr tries several alternatives in order until it finds the first one that doesn’t throw an error.
function pMap<A, B>(parser: Parser<A>, fn: (valor: A) => B): Parser<B> {
return (input) => {
const [valor, resto] = parser(input);
return [fn(valor), resto];
};
}
function pOr<T>(...parsers: Parser<T>[]): Parser<T> {
return (input) => {
for (const parser of parsers) {
try {
return parser(input);
} catch {
continue;
}
}
throw new Error("no alternative matched");
};
}
const pVerboHttp = pOr(pLiteral("GET"), pLiteral("POST"), pLiteral("DELETE"));
const pEntradaLog = pMap(
pSeq(pVerboHttp, pEspacio, pNumber),
([verbo, _, status]) => ({ verbo, status })
);
console.log(pEntradaLog("POST 201 ok"));
The output is [ { verbo: 'POST', status: 201 }, ' ok' ]. The combined parser now returns a useful object, not a positional tuple you have to remember how to read.
flowchart TD
A["Basic parsers: pNumber, pLiteral"] --> B["Sequence combinators: pSeq"]
A --> C["Choice combinators: pOr"]
B --> D["Transformation combinators: pMap"]
C --> D
D --> E["Repetition combinators: pSepBy"]
E --> F["Dependent combinators: pBind"]
Practical Examples and Getting Started
The combinator still missing for real-world cases is repetition. pSepBy parses a list of items separated by a delimiter, something a regex can handle for small cases but that becomes unreadable with nested separators.
function pSepBy<T>(item: Parser<T>, separador: Parser<unknown>): Parser<T[]> {
return (input) => {
const items: T[] = [];
let resto = input;
while (true) {
try {
const [valor, nuevoResto] = item(resto);
resto = nuevoResto;
items.push(valor);
const [, despuesDeSeparador] = separador(resto);
resto = despuesDeSeparador;
} catch {
break;
}
}
return [items, resto];
};
}
const pListaNumeros = pSepBy(pNumber, pLiteral(","));
console.log(pListaNumeros("10,20,30 end"));
The output is [ [ 10, 20, 30 ], ' end' ]. The loop uses while instead of recursion on purpose: recursion in JavaScript and TypeScript doesn’t get tail-call optimization, so a list with thousands of items resolved recursively can overflow the stack.
Getting Started
To run these examples as-is, you just need Node.js installed (version 18 or higher) and nothing else. The commands are the same on Linux, macOS, and Windows with PowerShell, because they’re npm commands, not system shell commands.
mkdir parser-combinators-demo && cd parser-combinators-demo
npm init -y
npm install --save-dev typescript ts-node @types/node
npx tsc --init
With the project created, save the code from the previous examples in a file called parser.ts and run it with:
npx ts-node parser.ts
To confirm the typing actually works, swap pNumber for a parser that returns string inside pSeq(pMetodo, pEspacio, pNumber) and run npx tsc --noEmit. The compiler has to fail with a type error on that line, before running anything.
Real-World Use Cases
Combinable parsers show up in production code more often than it seems. A backend team might use them to read log lines in a custom format, where each field has a different type and a regex would end up with capture groups that are impossible to maintain.
They also work well for homegrown configuration languages, the kind of syntax a team invents for its own internal tool that doesn’t justify a separate, formally versioned grammar. And they show up in simple network protocols: formats where one field indicates the length of what follows, as shown later in the section on dependent combinators.
Parsimmon is the most widely used combinator-parsing library in the JavaScript ecosystem, and it applies exactly this idea to parse JSON, non-standard date formats, or custom templating languages, without depending on an external generator.
Common Mistakes and Best Practices
- Order in pOr: the first alternative that matches wins, even if it’s not the most specific one. If
pLiteral("GET")andpLiteral("GETX")compete, put the longer one first, or you’ll cut the text wrong. - Forgotten spaces: if the format allows spaces between tokens, you have to consume them explicitly at every point where they can appear, not just at the start of the whole string.
- Unbounded recursion: a repetition combinator written with recursion instead of a loop can overflow the stack on long inputs, which is why
pSepByuseswhilein the earlier example. - Exceptions as control flow: using
throw/catchto signal that a parser failed is easy to read, but it mixes error handling with parsing logic. For production, it’s better to migrate to an explicitResulttype that distinguishes success from failure without throwing exceptions. - Error messages without position: a parser that only says “no match” is useless for debugging a file with thousands of lines. You need to wrap each parser so it also reports the index where it failed.
Comparison with Alternatives
None of these techniques is universally better; each one solves a different range of formats.
| Technique | When to Use It | Advantage | Limitation |
|---|---|---|---|
| Regex | Simple patterns, no nesting | Native in almost every language, quick to write | Unreadable and rigid with nested structure |
| Parser combinators | Custom formats with moderate structure: logs, configs, small DSLs | Composable, typed, testable parser by parser | Requires thinking in pure functions and manual backtracking |
| Parser generator (ANTLR) | Large languages with a stable formal grammar | Generates an optimal parser from a declarative grammar | Adds a build step and a grammar separate from the code |
| Hand-written parser (recursive descent) | Critical performance or full control over errors | No dependencies, absolute control | More repeated code and harder to maintain |
Going Deeper: Where This Idea Comes From
Parser combinators inherit this idea directly from Parsec, the Haskell library from the 1990s that popularized the pattern of composing parsers with operators instead of generating code from a grammar. The theoretical key is that each parser implements what Haskell calls a monad, a structure that defines how to chain operations that can fail or depend on a previous result.
💭 Key point: pSeq and pMap are enough to combine parsers that are independent of each other, but they don’t work when the second parser depends on the value the first one returned, not just its type. That requires one more combinator: pBind.
A real case of that dependency is a variable-length field: first you read a number, and that number tells you how many characters to read next. It’s the same pattern used by Redis’s RESP protocol and BitTorrent’s bencode format.
function pBind<A, B>(parser: Parser<A>, fn: (valor: A) => Parser<B>): Parser<B> {
return (input) => {
const [valor, resto] = parser(input);
return fn(valor)(resto);
};
}
const pTomar = (n: number): Parser<string> => (input) => [input.slice(0, n), input.slice(n)];
const pCampoConLongitud: Parser<string> = pBind(pNumber, (longitud) =>
pMap(pSeq(pLiteral(":"), pTomar(longitud)), ([, texto]) => texto)
);
console.log(pCampoConLongitud("5:helloworld"));
The output is [ 'hello', 'world' ]. pNumber reads the 5, and that value decides that pTomar has to cut exactly five characters after the colon. pSeq couldn’t express this because it doesn’t know the value of the previous parser, it only chains its remainder.
The other advanced piece is backtracking performance: when pOr tries several alternatives at the same position in the text, it can repeat work if those alternatives share a long prefix. The classic technique to avoid this is packrat parsing, which caches the result of each parser at each input position so it doesn’t get recalculated.
Your next step: take the pSepBy example from this guide, modify it to ignore optional spaces before and after the comma using a whitespace parser between item and separador, and test it against "10, 20 ,30" to see where it fails.
Frequently Asked Questions
Do Parser Combinators Replace a Formal Grammar Like BNF?
Not necessarily. For large, stable languages, a declarative grammar feeding a generator like ANTLR is still more maintainable. Parser combinators shine for small or changing formats where versioning a separate grammar isn’t worth it.
What’s the Difference Between Combinable Parsers and Regular Expressions?
A regex describes a flat pattern with hidden internal backtracking. A combinable parser is an explicit function that can be tested, composed, and typed separately; the cost is more code to solve the same simple pattern.
Is Combinator-Based Parsing Slower Than a Generated Parser?
It can be if backtracking traverses the same portion of text multiple times. The classic solution is packrat parsing, which caches the result of each parser at each position so work isn’t repeated.
Do I Need to Know Functional Programming to Use Parser Combinators?
It helps to understand higher-order functions, but you don’t need Haskell or monads to get started. pSeq, pMap, and pOr read and write like any other TypeScript or JavaScript function.
What Parser Combinator Libraries Exist for JavaScript or TypeScript?
Parsimmon is the most widely used one in the Node.js ecosystem. There are also variants designed for pure TypeScript, all built around the same idea of small parsers combined with higher-order functions.
Are Combinable Parsers Useful for Full Language Compilers?
Yes, with caveats. Real projects use them for the frontend of moderately sized languages, but when the grammar grows too large, maintenance costs tend to push toward a parser generator or a hand-written recursive descent parser.
References
- T3: Parser Combinators: the original article that inspired this guide, with the Parser<T> type and the pSeq and pHello examples.
- Wikipedia: Parser combinator: definition and historical context of the technique.
- Parsimmon on GitHub: combinator-parsing library for JavaScript used in production.
- ANTLR: the reference parser generator for large, formal grammars.
- tree-sitter: incremental parsing library that powers syntax highlighting in modern editors.
📱 Like this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Featured image: Foto de Max Chen en Unsplash
Did it work for you? Got a different error? Say so below: questions get answered and help the next reader.
Leave a comment
0 Comments