Composing parsers into a unified whole. A lightweight, TypeScript-first parser combinator library — write small parsers, combine them into big ones, get typed results back.
npm install unitas
import { grammar, run } from 'unitas';
import { choice, inner, separatedBy } from 'unitas/combinators';
import { char, regex } from 'unitas/terminals';
import { letters } from 'unitas/primitives';
const csv = grammar({
row: (p) => separatedBy(p.value, char(',')),
value: (p) => choice(p.quoted, p.unquoted),
quoted: () => inner(char('"'), regex(/^[^"]*/), char('"')),
unquoted: () => letters,
});
run(csv.row, 'a,b,c'); // ['a', 'b', 'c']
run(csv.row, '"a,b",c'); // ['a,b', 'c']
grammar, no forward declarationsNote: This library is in active development. The API may change before v1.0.0.
Nothing is re-exported across entry points, so your bundler only ever sees what you import. Code shared between them ships once, however many you import from.
| Import from | Contains |
|---|---|
unitas | Types, success/failure, run, grammar, lazy, memoize |
unitas/terminals | Factories that match input directly — char, string, regex, … |
unitas/primitives | Ready-made parsers — digit, letters, whitespace, … |
unitas/combinators | Parsers that take parsers — map, choice, sequence, many, … |
unitas/utils | Helpers for map callbacks — pick, join, pipe, … |
The library is broad on purpose: each function does one small thing, so grammars read like the format they parse. This table is the fastest way in — start from the intent, then look the name up in the API index.
| I want to match… | Reach for |
|---|---|
| an exact character or string | char, string |
| one of several strings (longest wins) | oneOf |
| any character in / not in a set | charOf, stringOf, noneOf |
| a character passing a predicate | satisfy |
| a regular expression | regex |
| a fixed number of characters, or a run | take, takeWhile |
| a keyword not glued to more word characters | word |
| a token, eating trailing whitespace | token, lexeme |
| a value — no need to build it | number, integer, float, bool, identifier, literal, line, rest |
| a character class | letter, digit, alphaNum, hexDigit, octDigit, lowercase, uppercase, anyChar |
| whitespace or a line break | whitespace, space, tab, nl, crlf, eol, eof |
Most primitives come in a singular and a plural form: letter matches one, letters matches a run of them. The same holds for digit/digits, alphaNum/alphaNums, hexDigit/hexDigits, octDigit/octDigits, lowercase/lowercases, uppercase/uppercases, space/spaces and whitespace/whitespaces. Need to know how far into the input you are? position.
| I want to… | Reach for |
|---|---|
| repeat zero or more / one or more times | many, many1 |
| repeat a bounded number of times | exactly, manyAtLeast, manyAtMost, manyBetween |
| repeat with a separator between items | separatedBy, separatedBy1, optionalSeparatedBy |
| …and allow a trailing separator | separatedEndBy, separatedEndBy1 |
| …where every item ends with the separator | endBy, endBy1, interleaved |
| repeat until something else shows up | manyTill, until, separatedUntil |
| repeat but throw the values away | skip, skipMany, skipMany1 |
| reduce the repetitions as I go | fold, fold1, foldRight, foldRight1 |
| I want to… | Reach for |
|---|---|
| take the first alternative that matches | choice |
| succeed either way | optional, optionalConsume, recover, flag |
| succeed only when something doesn't match | not |
| gate on a boolean I already have | guard, unless, when |
| look ahead without consuming input | peek |
| I want to… | Reach for |
|---|---|
| run parsers in order and keep every result | sequence |
| keep only one side of a pair | left, right |
| keep specific positions of a sequence | first, last, nth, pick |
| drop the delimiters, keep the middle | inner, outer |
| glue string results together | concat, fuse, join |
| match content inside delimiters | surrounded, parenthesized, braced, bracketed, quoted, padded |
| I want to… | Reach for |
|---|---|
| transform the parsed value | map, pipe |
| replace it with a constant | value, pure, consume |
| build a typed AST node | node |
| reject a value after parsing it | validate |
| choose the next parser from the last value | bind |
tidy up arrays inside a map | pick, filter, flatten, shift, pop, spread |
| I want to… | Reach for |
|---|---|
| let rules reference each other by name | grammar |
| refer to a parser defined further down | lazy |
| cache results and avoid exponential blowup | memoize |
| attach a readable error message | label |
| parse without throwing, with line and column | parse |
| turn an offset into a line and column | locate |
| render a failure with a caret under it | format, ParseError |
| record what a hand-written parser expected | context, record, union |
| parse infix operators with precedence | chainLeft, chainLeft1, chainRight, chainRight1 |
| parse prefix / postfix operators | prefix, postfix |
handle both outcomes of a Result | match |
Parser typeA Parser<T> reads input from an offset and returns a Result<T>. That is the whole abstraction — everything else in this library either produces one or wraps one. Both extra arguments are optional, so a parser can still be called with just an input.
type Parser<T> = (input: string, index?: number, ctx?: Context) => Result<T>;
Parsers never slice the input. They pass the same string down and move an integer instead, which is what lets an error know where in the original source it happened.
ctx is the parse context, created by run and parse. It is where error information accumulates — see Error reporting. If you write your own combinator, pass it on to every parser you call.
Result typetype Success<T> = { ok: true; value: T; index: number };
type Failure = { ok: false; index: number; expected: readonly string[] };
type Result<T> = Success<T> | Failure;
{ ok: true, value: 'hello', index: 5 }
│ │ │
│ │ └── how far this parser got
│ └── the parsed value
└── always true for success
index is the important part: it is how input gets consumed and how parsers chain. A failure reports the same index, plus the set of things that would have matched there:
{ ok: false, index: 6, expected: ['digit'] }
Combinators hand every alternative the same offset they started with, so a branch that fails can never leave the cursor moved. choice(a, b) always offers b the same starting offset, however far a got.
If you are coming from Parsec, this is why there is no try/attempt here — backtracking is unconditional, so there is nothing to opt into. What a failed branch does leave behind is its expectation, recorded in the parse context at the offset it reached, so a later error can still mention it.
Most of the time you compose existing pieces, but nothing stops you from dropping down a level:
import { create, success, failure } from 'unitas';
const parser = create<string>((input, index = 0, ctx) => {
if (input.startsWith('hello', index)) {
return success('hello', index + 5);
}
return failure(ctx, index, '"hello"');
});
Passing ctx to failure is what lets this parser's expectation appear in an error message even when a choice backtracks past it. Do the same for any parser you call:
const twice = <T>(parser: Parser<T>) =>
create<[T, T]>((input, index = 0, ctx) => {
const first = parser(input, index, ctx);
if (!first.ok) return first;
const second = parser(input, first.index, ctx);
if (!second.ok) return second;
return success([first.value, second.value], second.index);
});
grammar hands every rule a proxy p, so rules can reference each other — including themselves — without forward declarations or lazy boilerplate.
import { grammar, run } from 'unitas';
import { chainLeft1, choice, map, sequence } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { digits } from 'unitas/primitives';
type Math = { expr: number; term: number; value: number };
const g = grammar<Math>({
expr: (p) =>
chainLeft1(
p.term,
map(char('+'), () => (l: number, r: number) => l + r),
),
term: (p) =>
choice(
p.value,
map(sequence(char('('), p.expr, char(')')), ([, v]) => v),
),
value: () => digits,
});
run(g.expr, '1+2+3'); // 6
run(g.expr, '(1+2)'); // 3
When a parse fails, run throws a ParseError that says where and what was expected:
import { run } from 'unitas';
import { separatedBy, sequence } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { digits, letters } from 'unitas/primitives';
const pair = sequence(letters, char('='), digits);
const config = separatedBy(pair, char('\n'));
run(config, 'host=1\nport=8080\ndebug=yes');
// ParseError: 3:7 expected digit, found 'y'
// 3 | debug=yes
// | ^
The position is the furthest offset any branch reached, not wherever the parse happened to stop. That distinction is what makes the message useful: separatedBy succeeded with two pairs and left the rest unconsumed, but the error still points at the y that actually broke it.
Alternatives are reported together, since choice unions the expectations of every branch it tried at the same offset:
run(sequence(choice(string('let'), string('const')), char(' ')), 'lot x');
// ParseError: 1:1 expected 'const' or 'let', found 'l'
// 1 | lot x
// | ^
Use parse when you would rather branch on the failure than catch it:
import { parse } from 'unitas';
const result = parse(config, 'host=1\nport=oops');
if (!result.ok) {
result.line; // 2
result.column; // 6
result.expected; // ['digit']
result.message; // the formatted excerpt above
}
label replaces the expectations of a whole parser with one description, so errors can talk about your grammar instead of its characters:
import { label } from 'unitas';
run(label(pair, 'a key=value pair'), '!!!');
// ParseError: 1:1 expected a key=value pair, found '!'
JSON value — a tagged union of scalars
import { grammar, run } from 'unitas';
import { choice, quoted, value } from 'unitas/combinators';
import { string } from 'unitas/terminals';
import { bool, digits, letters } from 'unitas/primitives';
const json = grammar({
value: (p) => choice(p.string, p.number, p.bool, p.null),
string: () => quoted(letters),
number: () => digits,
bool: () => bool,
null: () => value(string('null'), null),
});
run(json.value, '"hello"'); // 'hello'
run(json.value, '42'); // 42
run(json.value, 'true'); // true
run(json.value, 'null'); // null
Query string — separated pairs folded into an object
import { grammar, run } from 'unitas';
import { map, outer, separatedBy } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { letters } from 'unitas/primitives';
type Query = {
params: Record<string, string>;
param: [string, string];
key: string;
value: string;
};
const query = grammar<Query>({
params: (p) =>
map(separatedBy(p.param, char('&')), (pairs) =>
Object.fromEntries(pairs),
),
param: (p) => outer(p.key, char('='), p.value),
key: () => letters,
value: () => letters,
});
run(query.params, 'foo=bar&baz=qux'); // { foo: 'bar', baz: 'qux' }
INI section — a header plus an entry, with the punctuation dropped
import { grammar, run } from 'unitas';
import { bracketed, map, outer, sequence } from 'unitas/combinators';
import { char, regex } from 'unitas/terminals';
import { letters, nl } from 'unitas/primitives';
import { pick } from 'unitas/utils';
const ini = grammar({
section: (p) =>
map(
sequence(bracketed(p.name), nl, p.entry),
pick(0, 2),
([name, entry]) => ({ name, entry }),
),
name: () => letters,
entry: (p) => outer(p.key, char('='), p.value),
key: () => letters,
value: () => regex(/^[^\n]+/),
});
run(ini.section, '[database]\nhost=localhost'); // { name: 'database', entry: ['host', 'localhost'] }
One line per export. Every name links to its full description and runnable example in the reference pages under doc/api.
unitasTypes, constructors and the grammar runner. 17 exports — full reference →
context | Creates a fresh context. run and parse make one per call. |
create | Creates a parser from a parser function. |
failure | Creates a failed result at an offset and notes it in the parse context. |
format | Renders a parse failure as a source excerpt with a caret under the offset. |
grammar | Creates a recursive grammar where rules can reference each other. |
label | Replaces the expectations of a parser with a single description. |
lazy | Defers parser creation, useful for recursive grammars. |
locate | Converts a character offset into a 1-based line and column. |
match | Pattern matching on a Result to handle success and failure cases. |
memoize | Memoizes a parser so each offset is parsed at most once (packrat caching). |
parse | Runs a parser over the whole input without throwing. |
ParseError | Error thrown by run when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset. |
record | Notes what was expected at an offset, keeping only the furthest one seen. |
reject | Creates a failed result from an expectation list that is already allocated, avoiding a fresh array on every failure. |
run | Runs a parser over the whole input and returns the value. |
success | Creates a successful result with a value and the offset reached in the input. |
union | Adds the entries of b to a, returning a itself when it already covers them. |
unitas/terminalsFactories that match the input string directly. 12 exports — full reference →
char | Parse a specific character. |
charOf | Parse any character from a set. |
noneOf | Parse any character not in the set. |
oneOf | Parse one string from a set of strings (longest match wins). |
regex | Parse with a regular expression. |
satisfy | Parse a character satisfying a predicate. |
string | Parse a specific string. |
stringOf | Parse first character that exists in string (like charOf but for a string). |
take | Take n characters. |
takeWhile | Takes characters while the predicate returns true. |
token | Parses a string as a token, skipping trailing whitespace. |
word | Parses a specific word and ensures it is not followed by word characters. |
unitas/primitivesReady-made parsers for the usual suspects. 33 exports — full reference →
alphaNum | Parse a single alphanumeric character. |
alphaNums | Parse one or more alphanumeric characters as a string. |
anyChar | Parse any single character. |
bool | Parse a boolean literal. |
crlf | Parse CRLF line ending. |
digit | Parse a single digit and return as number. |
digits | Parse one or more digits and return as a number. |
eof | Match the end of the input. |
eol | Parse end of line (\n, \r\n, or EOF). |
float | Parse a floating point number. |
hexDigit | Parse a single hexadecimal digit. |
hexDigits | Parse one or more hex digits as a string. |
identifier | Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. |
integer | Parse a signed integer. |
letter | Parse a single letter. |
letters | Parse one or more letters as a string. |
line | Parse until end of line. |
literal | Parse a word-like value including hyphens. |
lowercase | Parse a single lowercase letter. |
lowercases | Parse one or more lowercase letters as a string. |
nl | Parse a newline character. |
number | Parse an integer or float. |
octDigit | Parse a single octal digit. |
octDigits | Parse one or more octal digits as a string. |
position | Get the current offset into the input, without consuming anything. |
rest | Consume and return everything left in the input. |
space | Parse a single space character. |
spaces | Parse one or more spaces as a string. |
tab | Parse tab character. |
uppercase | Parses a single uppercase letter. |
uppercases | Parse one or more uppercase letters as a string. |
whitespace | Parses a single whitespace character. |
whitespaces | Parse one or more whitespaces as a string. |
unitas/combinatorsTake parsers, return a new parser. 64 exports — full reference →
bind | Chain parsers where the second parser depends on the first result. |
braced | Parse content surrounded by braces. |
bracketed | Parse content surrounded by brackets. |
chainLeft | Chain left-associative operations (right-to-left for same precedence). |
chainLeft1 | Chain left-associative operations (fails on empty input). |
chainRight | Chain right-associative operations. |
chainRight1 | Chain right-associative operations (fails on empty input). |
choice | Try each parser in order, return first success. |
concat | Join string array parser result into a single string. |
consume | Consume input but discard the result (return null). |
endBy | Zero or more items separated and ending with terminator. |
endBy1 | One or more items separated and ending with terminator. |
exactly | Parse exactly n occurrences. |
first | Extract the first element from a parser result array. |
flag | Return true if parser succeeds, false otherwise. |
fold | Fold zero or more occurrences into a single value. |
fold1 | Fold one or more occurrences into a single value. |
foldRight | Fold zero or more occurrences from the right into a single value. |
foldRight1 | Fold one or more occurrences from the right into a single value. |
fuse | Fuse multiple string parsers into a single one. |
guard | Conditionally apply parser based on a condition. |
inner | Extract inner value from surrounded content (like inner of braced). |
interleaved | Parse items separated by separators, keeping both in the result. |
last | Extract the last element from a parser result array. |
left | Keep only the left result from a sequence. |
lexeme | Parser that consumes trailing whitespace. |
many | Zero or more occurrences (never fails). |
many1 | One or more occurrences. |
manyAtLeast | Parse at least n occurrences. |
manyAtMost | Parse at most n occurrences (never fails). |
manyBetween | Parse between min and max occurrences. |
manyTill | Parse zero or more until terminator matches. |
map | Transform a parsed value through one or more functions. |
node | Create a node from parser fields. |
not | Succeed if parser fails (without consuming input). |
nth | Extract the nth element from a parser result array. |
optional | Make parser optional (return null on failure, without consuming input). |
optionalConsume | Consume input if the parser matches, discarding the result. |
optionalSeparatedBy | Parse items separated by a separator, allowing empty slots. |
outer | Extract outer values from a sequence of 3 parsers (skip middle). |
padded | Parse content surrounded by optional whitespace. |
parenthesized | Parse content surrounded by parentheses. |
peek | Look ahead without consuming input. |
postfix | Apply zero or more postfix operators to an atom. |
prefix | Apply zero or more prefix operators to an atom. |
pure | Always succeed with a value without consuming input. |
quoted | Parse content surrounded by single or double quotes. |
recover | Use fallback value when parser fails. |
right | Keep only the right result from a sequence. |
separatedBy | Parse zero or more items separated by a separator. |
separatedBy1 | Parse one or more items separated by a separator. |
separatedEndBy | Parse zero or more items separated by a separator, allowing a trailing one. |
separatedEndBy1 | Parse one or more items separated by a separator, allowing a trailing one. |
separatedUntil | Parse items separated by a separator, up to a terminator. |
sequence | Parse a sequence of parsers and return all results as an array. |
skip | Skip a parser n times. |
skipMany | Skip zero or more occurrences (never fails, returns null). |
skipMany1 | Skip one or more occurrences (fails if no matches). |
surrounded | Parse content surrounded by delimiters. |
unless | Parse unless condition is true (inverse of guard). |
until | Parse zero or more until terminator matches, leaving the terminator unconsumed. |
validate | Validate parsed value with a predicate. |
value | Replace parsed value with a constant. |
when | Branch on a boolean parser result. |
unitas/utilsPlain helpers for map callbacks. 8 exports — full reference →
filter | Exclude values from array. |
flatten | Flatten nested arrays. |
join | Join array elements into a string. |
pick | Pick elements from an array by index. |
pipe | Pipe parser functions together. |
pop | Get the last element of an array. |
shift | Get the first element of an array. |
spread | Collect spread arguments into an array. |
0.3.0 changes how a parser reports where it got to. Parsers now take an offset into the input instead of receiving a sliced suffix, which is what makes line/column error reporting possible.
If you only compose the built-in parsers, nothing changes. run, grammar, map, choice and everything built on them behave the same, and both new arguments are optional, so myParser('input') still works.
Two things do change for everyone:
// reading a result
result.remaining; // 0.2.x
input.slice(result.index); // 0.3.0 — or just use result.index
// a failed run
run(p, 'bad'); // 0.2.x: Error: Parsing failed: Unexpected error
run(p, 'bad'); // 0.3.0: ParseError, with .line, .column and .expected
If you write parsers by hand, the shape changed:
// 0.2.x
create<string>((input) => {
if (input.startsWith('hello')) {
return success('hello', input.slice(5));
}
return failure('expected "hello"');
});
// 0.3.0
create<string>((input, index = 0, ctx) => {
if (input.startsWith('hello', index)) {
return success('hello', index + 5);
}
return failure(ctx, index, '"hello"');
});
The rest, in full:
| 0.2.x | 0.3.0 |
|---|---|
Parser<T> = (input) => Result<T> | Parser<T> = (input, index?, ctx?) => Result<T> |
success(value, remaining) | success(value, index) |
failure() / failure(message) | failure(ctx, index, ...expected) |
{ ok: true, value, remaining } | { ok: true, value, index } |
{ ok: false, error?: string } | { ok: false, index, expected } |
match's failure: (error) => … | failure: (index, expected) => … |
position → remaining length | position → offset from the start |
label(p, 'x') → 'expected x' | label(p, 'x') → expected: ['x'], only when nothing consumed |
regex(/^\w+/) | same, but sticky — a leading ^ is stripped unless multiline |
New in this release: parse (non-throwing), ParseError, locate, format, and context/record for hand-written parsers. memoize is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive grammar rules now throw a named error instead of overflowing the stack.
npm test # run the suite
npm run build # compile to dist/
npm run generate:index # regenerate barrel files after adding a source file
npm run generate:readme # regenerate README.md and doc/api/ from JSDoc
Every export lives in its own file with a co-located test and a JSDoc @example. Those examples are the single source of truth: they are extracted into test/examples.test.ts and into the documentation, so they cannot drift.
MIT © Oleg Kamlowski
Composing parsers into a unified whole. A lightweight, TypeScript-first parser combinator library — write small parsers, combine them into big ones, get typed results back.
npm install unitas
import { grammar, run } from 'unitas';
import { choice, inner, separatedBy } from 'unitas/combinators';
import { char, regex } from 'unitas/terminals';
import { letters } from 'unitas/primitives';
const csv = grammar({
row: (p) => separatedBy(p.value, char(',')),
value: (p) => choice(p.quoted, p.unquoted),
quoted: () => inner(char('"'), regex(/^[^"]*/), char('"')),
unquoted: () => letters,
});
run(csv.row, 'a,b,c'); // ['a', 'b', 'c']
run(csv.row, '"a,b",c'); // ['a,b', 'c']
grammar, no forward declarationsNote: This library is in active development. The API may change before v1.0.0.
Nothing is re-exported across entry points, so your bundler only ever sees what you import. Code shared between them ships once, however many you import from.
| Import from | Contains |
|---|---|
unitas | Types, success/failure, run, grammar, lazy, memoize |
unitas/terminals | Factories that match input directly — char, string, regex, … |
unitas/primitives | Ready-made parsers — digit, letters, whitespace, … |
unitas/combinators | Parsers that take parsers — map, choice, sequence, many, … |
unitas/utils | Helpers for map callbacks — pick, join, pipe, … |
The library is broad on purpose: each function does one small thing, so grammars read like the format they parse. This table is the fastest way in — start from the intent, then look the name up in the API index.
| I want to match… | Reach for |
|---|---|
| an exact character or string | char, string |
| one of several strings (longest wins) | oneOf |
| any character in / not in a set | charOf, stringOf, noneOf |
| a character passing a predicate | satisfy |
| a regular expression | regex |
| a fixed number of characters, or a run | take, takeWhile |
| a keyword not glued to more word characters | word |
| a token, eating trailing whitespace | token, lexeme |
| a value — no need to build it | number, integer, float, bool, identifier, literal, line, rest |
| a character class | letter, digit, alphaNum, hexDigit, octDigit, lowercase, uppercase, anyChar |
| whitespace or a line break | whitespace, space, tab, nl, crlf, eol, eof |
Most primitives come in a singular and a plural form: letter matches one, letters matches a run of them. The same holds for digit/digits, alphaNum/alphaNums, hexDigit/hexDigits, octDigit/octDigits, lowercase/lowercases, uppercase/uppercases, space/spaces and whitespace/whitespaces. Need to know how far into the input you are? position.
| I want to… | Reach for |
|---|---|
| repeat zero or more / one or more times | many, many1 |
| repeat a bounded number of times | exactly, manyAtLeast, manyAtMost, manyBetween |
| repeat with a separator between items | separatedBy, separatedBy1, optionalSeparatedBy |
| …and allow a trailing separator | separatedEndBy, separatedEndBy1 |
| …where every item ends with the separator | endBy, endBy1, interleaved |
| repeat until something else shows up | manyTill, until, separatedUntil |
| repeat but throw the values away | skip, skipMany, skipMany1 |
| reduce the repetitions as I go | fold, fold1, foldRight, foldRight1 |
| I want to… | Reach for |
|---|---|
| take the first alternative that matches | choice |
| succeed either way | optional, optionalConsume, recover, flag |
| succeed only when something doesn't match | not |
| gate on a boolean I already have | guard, unless, when |
| look ahead without consuming input | peek |
| I want to… | Reach for |
|---|---|
| run parsers in order and keep every result | sequence |
| keep only one side of a pair | left, right |
| keep specific positions of a sequence | first, last, nth, pick |
| drop the delimiters, keep the middle | inner, outer |
| glue string results together | concat, fuse, join |
| match content inside delimiters | surrounded, parenthesized, braced, bracketed, quoted, padded |
| I want to… | Reach for |
|---|---|
| transform the parsed value | map, pipe |
| replace it with a constant | value, pure, consume |
| build a typed AST node | node |
| reject a value after parsing it | validate |
| choose the next parser from the last value | bind |
tidy up arrays inside a map | pick, filter, flatten, shift, pop, spread |
| I want to… | Reach for |
|---|---|
| let rules reference each other by name | grammar |
| refer to a parser defined further down | lazy |
| cache results and avoid exponential blowup | memoize |
| attach a readable error message | label |
| parse without throwing, with line and column | parse |
| turn an offset into a line and column | locate |
| render a failure with a caret under it | format, ParseError |
| record what a hand-written parser expected | context, record, union |
| parse infix operators with precedence | chainLeft, chainLeft1, chainRight, chainRight1 |
| parse prefix / postfix operators | prefix, postfix |
handle both outcomes of a Result | match |
Parser typeA Parser<T> reads input from an offset and returns a Result<T>. That is the whole abstraction — everything else in this library either produces one or wraps one. Both extra arguments are optional, so a parser can still be called with just an input.
type Parser<T> = (input: string, index?: number, ctx?: Context) => Result<T>;
Parsers never slice the input. They pass the same string down and move an integer instead, which is what lets an error know where in the original source it happened.
ctx is the parse context, created by run and parse. It is where error information accumulates — see Error reporting. If you write your own combinator, pass it on to every parser you call.
Result typetype Success<T> = { ok: true; value: T; index: number };
type Failure = { ok: false; index: number; expected: readonly string[] };
type Result<T> = Success<T> | Failure;
{ ok: true, value: 'hello', index: 5 }
│ │ │
│ │ └── how far this parser got
│ └── the parsed value
└── always true for success
index is the important part: it is how input gets consumed and how parsers chain. A failure reports the same index, plus the set of things that would have matched there:
{ ok: false, index: 6, expected: ['digit'] }
Combinators hand every alternative the same offset they started with, so a branch that fails can never leave the cursor moved. choice(a, b) always offers b the same starting offset, however far a got.
If you are coming from Parsec, this is why there is no try/attempt here — backtracking is unconditional, so there is nothing to opt into. What a failed branch does leave behind is its expectation, recorded in the parse context at the offset it reached, so a later error can still mention it.
Most of the time you compose existing pieces, but nothing stops you from dropping down a level:
import { create, success, failure } from 'unitas';
const parser = create<string>((input, index = 0, ctx) => {
if (input.startsWith('hello', index)) {
return success('hello', index + 5);
}
return failure(ctx, index, '"hello"');
});
Passing ctx to failure is what lets this parser's expectation appear in an error message even when a choice backtracks past it. Do the same for any parser you call:
const twice = <T>(parser: Parser<T>) =>
create<[T, T]>((input, index = 0, ctx) => {
const first = parser(input, index, ctx);
if (!first.ok) return first;
const second = parser(input, first.index, ctx);
if (!second.ok) return second;
return success([first.value, second.value], second.index);
});
grammar hands every rule a proxy p, so rules can reference each other — including themselves — without forward declarations or lazy boilerplate.
import { grammar, run } from 'unitas';
import { chainLeft1, choice, map, sequence } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { digits } from 'unitas/primitives';
type Math = { expr: number; term: number; value: number };
const g = grammar<Math>({
expr: (p) =>
chainLeft1(
p.term,
map(char('+'), () => (l: number, r: number) => l + r),
),
term: (p) =>
choice(
p.value,
map(sequence(char('('), p.expr, char(')')), ([, v]) => v),
),
value: () => digits,
});
run(g.expr, '1+2+3'); // 6
run(g.expr, '(1+2)'); // 3
When a parse fails, run throws a ParseError that says where and what was expected:
import { run } from 'unitas';
import { separatedBy, sequence } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { digits, letters } from 'unitas/primitives';
const pair = sequence(letters, char('='), digits);
const config = separatedBy(pair, char('\n'));
run(config, 'host=1\nport=8080\ndebug=yes');
// ParseError: 3:7 expected digit, found 'y'
// 3 | debug=yes
// | ^
The position is the furthest offset any branch reached, not wherever the parse happened to stop. That distinction is what makes the message useful: separatedBy succeeded with two pairs and left the rest unconsumed, but the error still points at the y that actually broke it.
Alternatives are reported together, since choice unions the expectations of every branch it tried at the same offset:
run(sequence(choice(string('let'), string('const')), char(' ')), 'lot x');
// ParseError: 1:1 expected 'const' or 'let', found 'l'
// 1 | lot x
// | ^
Use parse when you would rather branch on the failure than catch it:
import { parse } from 'unitas';
const result = parse(config, 'host=1\nport=oops');
if (!result.ok) {
result.line; // 2
result.column; // 6
result.expected; // ['digit']
result.message; // the formatted excerpt above
}
label replaces the expectations of a whole parser with one description, so errors can talk about your grammar instead of its characters:
import { label } from 'unitas';
run(label(pair, 'a key=value pair'), '!!!');
// ParseError: 1:1 expected a key=value pair, found '!'
JSON value — a tagged union of scalars
import { grammar, run } from 'unitas';
import { choice, quoted, value } from 'unitas/combinators';
import { string } from 'unitas/terminals';
import { bool, digits, letters } from 'unitas/primitives';
const json = grammar({
value: (p) => choice(p.string, p.number, p.bool, p.null),
string: () => quoted(letters),
number: () => digits,
bool: () => bool,
null: () => value(string('null'), null),
});
run(json.value, '"hello"'); // 'hello'
run(json.value, '42'); // 42
run(json.value, 'true'); // true
run(json.value, 'null'); // null
Query string — separated pairs folded into an object
import { grammar, run } from 'unitas';
import { map, outer, separatedBy } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { letters } from 'unitas/primitives';
type Query = {
params: Record<string, string>;
param: [string, string];
key: string;
value: string;
};
const query = grammar<Query>({
params: (p) =>
map(separatedBy(p.param, char('&')), (pairs) =>
Object.fromEntries(pairs),
),
param: (p) => outer(p.key, char('='), p.value),
key: () => letters,
value: () => letters,
});
run(query.params, 'foo=bar&baz=qux'); // { foo: 'bar', baz: 'qux' }
INI section — a header plus an entry, with the punctuation dropped
import { grammar, run } from 'unitas';
import { bracketed, map, outer, sequence } from 'unitas/combinators';
import { char, regex } from 'unitas/terminals';
import { letters, nl } from 'unitas/primitives';
import { pick } from 'unitas/utils';
const ini = grammar({
section: (p) =>
map(
sequence(bracketed(p.name), nl, p.entry),
pick(0, 2),
([name, entry]) => ({ name, entry }),
),
name: () => letters,
entry: (p) => outer(p.key, char('='), p.value),
key: () => letters,
value: () => regex(/^[^\n]+/),
});
run(ini.section, '[database]\nhost=localhost'); // { name: 'database', entry: ['host', 'localhost'] }
One line per export. Every name links to its full description and runnable example in the reference pages under doc/api.
unitasTypes, constructors and the grammar runner. 17 exports — full reference →
context | Creates a fresh context. run and parse make one per call. |
create | Creates a parser from a parser function. |
failure | Creates a failed result at an offset and notes it in the parse context. |
format | Renders a parse failure as a source excerpt with a caret under the offset. |
grammar | Creates a recursive grammar where rules can reference each other. |
label | Replaces the expectations of a parser with a single description. |
lazy | Defers parser creation, useful for recursive grammars. |
locate | Converts a character offset into a 1-based line and column. |
match | Pattern matching on a Result to handle success and failure cases. |
memoize | Memoizes a parser so each offset is parsed at most once (packrat caching). |
parse | Runs a parser over the whole input without throwing. |
ParseError | Error thrown by run when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset. |
record | Notes what was expected at an offset, keeping only the furthest one seen. |
reject | Creates a failed result from an expectation list that is already allocated, avoiding a fresh array on every failure. |
run | Runs a parser over the whole input and returns the value. |
success | Creates a successful result with a value and the offset reached in the input. |
union | Adds the entries of b to a, returning a itself when it already covers them. |
unitas/terminalsFactories that match the input string directly. 12 exports — full reference →
char | Parse a specific character. |
charOf | Parse any character from a set. |
noneOf | Parse any character not in the set. |
oneOf | Parse one string from a set of strings (longest match wins). |
regex | Parse with a regular expression. |
satisfy | Parse a character satisfying a predicate. |
string | Parse a specific string. |
stringOf | Parse first character that exists in string (like charOf but for a string). |
take | Take n characters. |
takeWhile | Takes characters while the predicate returns true. |
token | Parses a string as a token, skipping trailing whitespace. |
word | Parses a specific word and ensures it is not followed by word characters. |
unitas/primitivesReady-made parsers for the usual suspects. 33 exports — full reference →
alphaNum | Parse a single alphanumeric character. |
alphaNums | Parse one or more alphanumeric characters as a string. |
anyChar | Parse any single character. |
bool | Parse a boolean literal. |
crlf | Parse CRLF line ending. |
digit | Parse a single digit and return as number. |
digits | Parse one or more digits and return as a number. |
eof | Match the end of the input. |
eol | Parse end of line (\n, \r\n, or EOF). |
float | Parse a floating point number. |
hexDigit | Parse a single hexadecimal digit. |
hexDigits | Parse one or more hex digits as a string. |
identifier | Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. |
integer | Parse a signed integer. |
letter | Parse a single letter. |
letters | Parse one or more letters as a string. |
line | Parse until end of line. |
literal | Parse a word-like value including hyphens. |
lowercase | Parse a single lowercase letter. |
lowercases | Parse one or more lowercase letters as a string. |
nl | Parse a newline character. |
number | Parse an integer or float. |
octDigit | Parse a single octal digit. |
octDigits | Parse one or more octal digits as a string. |
position | Get the current offset into the input, without consuming anything. |
rest | Consume and return everything left in the input. |
space | Parse a single space character. |
spaces | Parse one or more spaces as a string. |
tab | Parse tab character. |
uppercase | Parses a single uppercase letter. |
uppercases | Parse one or more uppercase letters as a string. |
whitespace | Parses a single whitespace character. |
whitespaces | Parse one or more whitespaces as a string. |
unitas/combinatorsTake parsers, return a new parser. 64 exports — full reference →
bind | Chain parsers where the second parser depends on the first result. |
braced | Parse content surrounded by braces. |
bracketed | Parse content surrounded by brackets. |
chainLeft | Chain left-associative operations (right-to-left for same precedence). |
chainLeft1 | Chain left-associative operations (fails on empty input). |
chainRight | Chain right-associative operations. |
chainRight1 | Chain right-associative operations (fails on empty input). |
choice | Try each parser in order, return first success. |
concat | Join string array parser result into a single string. |
consume | Consume input but discard the result (return null). |
endBy | Zero or more items separated and ending with terminator. |
endBy1 | One or more items separated and ending with terminator. |
exactly | Parse exactly n occurrences. |
first | Extract the first element from a parser result array. |
flag | Return true if parser succeeds, false otherwise. |
fold | Fold zero or more occurrences into a single value. |
fold1 | Fold one or more occurrences into a single value. |
foldRight | Fold zero or more occurrences from the right into a single value. |
foldRight1 | Fold one or more occurrences from the right into a single value. |
fuse | Fuse multiple string parsers into a single one. |
guard | Conditionally apply parser based on a condition. |
inner | Extract inner value from surrounded content (like inner of braced). |
interleaved | Parse items separated by separators, keeping both in the result. |
last | Extract the last element from a parser result array. |
left | Keep only the left result from a sequence. |
lexeme | Parser that consumes trailing whitespace. |
many | Zero or more occurrences (never fails). |
many1 | One or more occurrences. |
manyAtLeast | Parse at least n occurrences. |
manyAtMost | Parse at most n occurrences (never fails). |
manyBetween | Parse between min and max occurrences. |
manyTill | Parse zero or more until terminator matches. |
map | Transform a parsed value through one or more functions. |
node | Create a node from parser fields. |
not | Succeed if parser fails (without consuming input). |
nth | Extract the nth element from a parser result array. |
optional | Make parser optional (return null on failure, without consuming input). |
optionalConsume | Consume input if the parser matches, discarding the result. |
optionalSeparatedBy | Parse items separated by a separator, allowing empty slots. |
outer | Extract outer values from a sequence of 3 parsers (skip middle). |
padded | Parse content surrounded by optional whitespace. |
parenthesized | Parse content surrounded by parentheses. |
peek | Look ahead without consuming input. |
postfix | Apply zero or more postfix operators to an atom. |
prefix | Apply zero or more prefix operators to an atom. |
pure | Always succeed with a value without consuming input. |
quoted | Parse content surrounded by single or double quotes. |
recover | Use fallback value when parser fails. |
right | Keep only the right result from a sequence. |
separatedBy | Parse zero or more items separated by a separator. |
separatedBy1 | Parse one or more items separated by a separator. |
separatedEndBy | Parse zero or more items separated by a separator, allowing a trailing one. |
separatedEndBy1 | Parse one or more items separated by a separator, allowing a trailing one. |
separatedUntil | Parse items separated by a separator, up to a terminator. |
sequence | Parse a sequence of parsers and return all results as an array. |
skip | Skip a parser n times. |
skipMany | Skip zero or more occurrences (never fails, returns null). |
skipMany1 | Skip one or more occurrences (fails if no matches). |
surrounded | Parse content surrounded by delimiters. |
unless | Parse unless condition is true (inverse of guard). |
until | Parse zero or more until terminator matches, leaving the terminator unconsumed. |
validate | Validate parsed value with a predicate. |
value | Replace parsed value with a constant. |
when | Branch on a boolean parser result. |
unitas/utilsPlain helpers for map callbacks. 8 exports — full reference →
filter | Exclude values from array. |
flatten | Flatten nested arrays. |
join | Join array elements into a string. |
pick | Pick elements from an array by index. |
pipe | Pipe parser functions together. |
pop | Get the last element of an array. |
shift | Get the first element of an array. |
spread | Collect spread arguments into an array. |
0.3.0 changes how a parser reports where it got to. Parsers now take an offset into the input instead of receiving a sliced suffix, which is what makes line/column error reporting possible.
If you only compose the built-in parsers, nothing changes. run, grammar, map, choice and everything built on them behave the same, and both new arguments are optional, so myParser('input') still works.
Two things do change for everyone:
// reading a result
result.remaining; // 0.2.x
input.slice(result.index); // 0.3.0 — or just use result.index
// a failed run
run(p, 'bad'); // 0.2.x: Error: Parsing failed: Unexpected error
run(p, 'bad'); // 0.3.0: ParseError, with .line, .column and .expected
If you write parsers by hand, the shape changed:
// 0.2.x
create<string>((input) => {
if (input.startsWith('hello')) {
return success('hello', input.slice(5));
}
return failure('expected "hello"');
});
// 0.3.0
create<string>((input, index = 0, ctx) => {
if (input.startsWith('hello', index)) {
return success('hello', index + 5);
}
return failure(ctx, index, '"hello"');
});
The rest, in full:
| 0.2.x | 0.3.0 |
|---|---|
Parser<T> = (input) => Result<T> | Parser<T> = (input, index?, ctx?) => Result<T> |
success(value, remaining) | success(value, index) |
failure() / failure(message) | failure(ctx, index, ...expected) |
{ ok: true, value, remaining } | { ok: true, value, index } |
{ ok: false, error?: string } | { ok: false, index, expected } |
match's failure: (error) => … | failure: (index, expected) => … |
position → remaining length | position → offset from the start |
label(p, 'x') → 'expected x' | label(p, 'x') → expected: ['x'], only when nothing consumed |
regex(/^\w+/) | same, but sticky — a leading ^ is stripped unless multiline |
New in this release: parse (non-throwing), ParseError, locate, format, and context/record for hand-written parsers. memoize is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive grammar rules now throw a named error instead of overflowing the stack.
npm test # run the suite
npm run build # compile to dist/
npm run generate:index # regenerate barrel files after adding a source file
npm run generate:readme # regenerate README.md and doc/api/ from JSDoc
Every export lives in its own file with a co-located test and a JSDoc @example. Those examples are the single source of truth: they are extracted into test/examples.test.ts and into the documentation, so they cannot drift.
MIT © Oleg Kamlowski