sovrin/unitas

Monadic parser combinators for TypeScript

TypeScript

0

153 commits

updated Oct 19, 2026

See the code

README

unitas

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 version Coverage License: MIT

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']
  • Zero dependencies, ESM-only, fully typed
  • Tree-shakeable — five entry points, import only what you touch
  • 134 composable exports — grouped by what they do, indexed below
  • Mutual recursion out of the box via grammar, no forward declarations
  • Errors that point at the problem — line, column and what was expected

Note: This library is in active development. The API may change before v1.0.0.

Contents

Entry points

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 fromContains
unitasTypes, success/failure, run, grammar, lazy, memoize
unitas/terminalsFactories that match input directly — char, string, regex, …
unitas/primitivesReady-made parsers — digit, letters, whitespace, …
unitas/combinatorsParsers that take parsers — map, choice, sequence, many, …
unitas/utilsHelpers for map callbacks — pick, join, pipe, …

Which function do I need?

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.

Matching a single thing

I want to match…Reach for
an exact character or stringchar, string
one of several strings (longest wins)oneOf
any character in / not in a setcharOf, stringOf, noneOf
a character passing a predicatesatisfy
a regular expressionregex
a fixed number of characters, or a runtake, takeWhile
a keyword not glued to more word charactersword
a token, eating trailing whitespacetoken, lexeme
a value — no need to build itnumber, integer, float, bool, identifier, literal, line, rest
a character classletter, digit, alphaNum, hexDigit, octDigit, lowercase, uppercase, anyChar
whitespace or a line breakwhitespace, 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.

Repeating

I want to…Reach for
repeat zero or more / one or more timesmany, many1
repeat a bounded number of timesexactly, manyAtLeast, manyAtMost, manyBetween
repeat with a separator between itemsseparatedBy, separatedBy1, optionalSeparatedBy
…and allow a trailing separatorseparatedEndBy, separatedEndBy1
…where every item ends with the separatorendBy, endBy1, interleaved
repeat until something else shows upmanyTill, until, separatedUntil
repeat but throw the values awayskip, skipMany, skipMany1
reduce the repetitions as I gofold, fold1, foldRight, foldRight1

Choosing and branching

I want to…Reach for
take the first alternative that matcheschoice
succeed either wayoptional, optionalConsume, recover, flag
succeed only when something doesn't matchnot
gate on a boolean I already haveguard, unless, when
look ahead without consuming inputpeek

Sequencing and pulling values out

I want to…Reach for
run parsers in order and keep every resultsequence
keep only one side of a pairleft, right
keep specific positions of a sequencefirst, last, nth, pick
drop the delimiters, keep the middleinner, outer
glue string results togetherconcat, fuse, join
match content inside delimiterssurrounded, parenthesized, braced, bracketed, quoted, padded

Shaping the result

I want to…Reach for
transform the parsed valuemap, pipe
replace it with a constantvalue, pure, consume
build a typed AST nodenode
reject a value after parsing itvalidate
choose the next parser from the last valuebind
tidy up arrays inside a mappick, filter, flatten, shift, pop, spread

Grammars, recursion and errors

I want to…Reach for
let rules reference each other by namegrammar
refer to a parser defined further downlazy
cache results and avoid exponential blowupmemoize
attach a readable error messagelabel
parse without throwing, with line and columnparse
turn an offset into a line and columnlocate
render a failure with a caret under itformat, ParseError
record what a hand-written parser expectedcontext, record, union
parse infix operators with precedencechainLeft, chainLeft1, chainRight, chainRight1
parse prefix / postfix operatorsprefix, postfix
handle both outcomes of a Resultmatch

Core concepts

The Parser type

A 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.

The Result type

type 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'] }

Backtracking is free

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.

Writing one by hand

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);
    });

Grammars

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

Error reporting

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 '!'

More examples

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'] }

API index

One line per export. Every name links to its full description and runnable example in the reference pages under doc/api.

Core — unitas

Types, constructors and the grammar runner. 17 exports — full reference →

contextCreates a fresh context. run and parse make one per call.
createCreates a parser from a parser function.
failureCreates a failed result at an offset and notes it in the parse context.
formatRenders a parse failure as a source excerpt with a caret under the offset.
grammarCreates a recursive grammar where rules can reference each other.
labelReplaces the expectations of a parser with a single description.
lazyDefers parser creation, useful for recursive grammars.
locateConverts a character offset into a 1-based line and column.
matchPattern matching on a Result to handle success and failure cases.
memoizeMemoizes a parser so each offset is parsed at most once (packrat caching).
parseRuns a parser over the whole input without throwing.
ParseErrorError thrown by run when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset.
recordNotes what was expected at an offset, keeping only the furthest one seen.
rejectCreates a failed result from an expectation list that is already allocated, avoiding a fresh array on every failure.
runRuns a parser over the whole input and returns the value.
successCreates a successful result with a value and the offset reached in the input.
unionAdds the entries of b to a, returning a itself when it already covers them.

Terminals — unitas/terminals

Factories that match the input string directly. 12 exports — full reference →

charParse a specific character.
charOfParse any character from a set.
noneOfParse any character not in the set.
oneOfParse one string from a set of strings (longest match wins).
regexParse with a regular expression.
satisfyParse a character satisfying a predicate.
stringParse a specific string.
stringOfParse first character that exists in string (like charOf but for a string).
takeTake n characters.
takeWhileTakes characters while the predicate returns true.
tokenParses a string as a token, skipping trailing whitespace.
wordParses a specific word and ensures it is not followed by word characters.

Primitives — unitas/primitives

Ready-made parsers for the usual suspects. 33 exports — full reference →

alphaNumParse a single alphanumeric character.
alphaNumsParse one or more alphanumeric characters as a string.
anyCharParse any single character.
boolParse a boolean literal.
crlfParse CRLF line ending.
digitParse a single digit and return as number.
digitsParse one or more digits and return as a number.
eofMatch the end of the input.
eolParse end of line (\n, \r\n, or EOF).
floatParse a floating point number.
hexDigitParse a single hexadecimal digit.
hexDigitsParse one or more hex digits as a string.
identifierParse an identifier — starts with letter or underscore, no leading digit, no hyphen.
integerParse a signed integer.
letterParse a single letter.
lettersParse one or more letters as a string.
lineParse until end of line.
literalParse a word-like value including hyphens.
lowercaseParse a single lowercase letter.
lowercasesParse one or more lowercase letters as a string.
nlParse a newline character.
numberParse an integer or float.
octDigitParse a single octal digit.
octDigitsParse one or more octal digits as a string.
positionGet the current offset into the input, without consuming anything.
restConsume and return everything left in the input.
spaceParse a single space character.
spacesParse one or more spaces as a string.
tabParse tab character.
uppercaseParses a single uppercase letter.
uppercasesParse one or more uppercase letters as a string.
whitespaceParses a single whitespace character.
whitespacesParse one or more whitespaces as a string.

Combinators — unitas/combinators

Take parsers, return a new parser. 64 exports — full reference →

bindChain parsers where the second parser depends on the first result.
bracedParse content surrounded by braces.
bracketedParse content surrounded by brackets.
chainLeftChain left-associative operations (right-to-left for same precedence).
chainLeft1Chain left-associative operations (fails on empty input).
chainRightChain right-associative operations.
chainRight1Chain right-associative operations (fails on empty input).
choiceTry each parser in order, return first success.
concatJoin string array parser result into a single string.
consumeConsume input but discard the result (return null).
endByZero or more items separated and ending with terminator.
endBy1One or more items separated and ending with terminator.
exactlyParse exactly n occurrences.
firstExtract the first element from a parser result array.
flagReturn true if parser succeeds, false otherwise.
foldFold zero or more occurrences into a single value.
fold1Fold one or more occurrences into a single value.
foldRightFold zero or more occurrences from the right into a single value.
foldRight1Fold one or more occurrences from the right into a single value.
fuseFuse multiple string parsers into a single one.
guardConditionally apply parser based on a condition.
innerExtract inner value from surrounded content (like inner of braced).
interleavedParse items separated by separators, keeping both in the result.
lastExtract the last element from a parser result array.
leftKeep only the left result from a sequence.
lexemeParser that consumes trailing whitespace.
manyZero or more occurrences (never fails).
many1One or more occurrences.
manyAtLeastParse at least n occurrences.
manyAtMostParse at most n occurrences (never fails).
manyBetweenParse between min and max occurrences.
manyTillParse zero or more until terminator matches.
mapTransform a parsed value through one or more functions.
nodeCreate a node from parser fields.
notSucceed if parser fails (without consuming input).
nthExtract the nth element from a parser result array.
optionalMake parser optional (return null on failure, without consuming input).
optionalConsumeConsume input if the parser matches, discarding the result.
optionalSeparatedByParse items separated by a separator, allowing empty slots.
outerExtract outer values from a sequence of 3 parsers (skip middle).
paddedParse content surrounded by optional whitespace.
parenthesizedParse content surrounded by parentheses.
peekLook ahead without consuming input.
postfixApply zero or more postfix operators to an atom.
prefixApply zero or more prefix operators to an atom.
pureAlways succeed with a value without consuming input.
quotedParse content surrounded by single or double quotes.
recoverUse fallback value when parser fails.
rightKeep only the right result from a sequence.
separatedByParse zero or more items separated by a separator.
separatedBy1Parse one or more items separated by a separator.
separatedEndByParse zero or more items separated by a separator, allowing a trailing one.
separatedEndBy1Parse one or more items separated by a separator, allowing a trailing one.
separatedUntilParse items separated by a separator, up to a terminator.
sequenceParse a sequence of parsers and return all results as an array.
skipSkip a parser n times.
skipManySkip zero or more occurrences (never fails, returns null).
skipMany1Skip one or more occurrences (fails if no matches).
surroundedParse content surrounded by delimiters.
unlessParse unless condition is true (inverse of guard).
untilParse zero or more until terminator matches, leaving the terminator unconsumed.
validateValidate parsed value with a predicate.
valueReplace parsed value with a constant.
whenBranch on a boolean parser result.

Utils — unitas/utils

Plain helpers for map callbacks. 8 exports — full reference →

filterExclude values from array.
flattenFlatten nested arrays.
joinJoin array elements into a string.
pickPick elements from an array by index.
pipePipe parser functions together.
popGet the last element of an array.
shiftGet the first element of an array.
spreadCollect spread arguments into an array.

Migrating from 0.2.x

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.x0.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 lengthposition → 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.

Contributing

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.

License

MIT © Oleg Kamlowski

sovrin/unitas

Monadic parser combinators for TypeScript

TypeScript

0

153 commits

updated Oct 19, 2026

See the code

README

unitas

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 version Coverage License: MIT

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']
  • Zero dependencies, ESM-only, fully typed
  • Tree-shakeable — five entry points, import only what you touch
  • 134 composable exports — grouped by what they do, indexed below
  • Mutual recursion out of the box via grammar, no forward declarations
  • Errors that point at the problem — line, column and what was expected

Note: This library is in active development. The API may change before v1.0.0.

Contents

Entry points

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 fromContains
unitasTypes, success/failure, run, grammar, lazy, memoize
unitas/terminalsFactories that match input directly — char, string, regex, …
unitas/primitivesReady-made parsers — digit, letters, whitespace, …
unitas/combinatorsParsers that take parsers — map, choice, sequence, many, …
unitas/utilsHelpers for map callbacks — pick, join, pipe, …

Which function do I need?

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.

Matching a single thing

I want to match…Reach for
an exact character or stringchar, string
one of several strings (longest wins)oneOf
any character in / not in a setcharOf, stringOf, noneOf
a character passing a predicatesatisfy
a regular expressionregex
a fixed number of characters, or a runtake, takeWhile
a keyword not glued to more word charactersword
a token, eating trailing whitespacetoken, lexeme
a value — no need to build itnumber, integer, float, bool, identifier, literal, line, rest
a character classletter, digit, alphaNum, hexDigit, octDigit, lowercase, uppercase, anyChar
whitespace or a line breakwhitespace, 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.

Repeating

I want to…Reach for
repeat zero or more / one or more timesmany, many1
repeat a bounded number of timesexactly, manyAtLeast, manyAtMost, manyBetween
repeat with a separator between itemsseparatedBy, separatedBy1, optionalSeparatedBy
…and allow a trailing separatorseparatedEndBy, separatedEndBy1
…where every item ends with the separatorendBy, endBy1, interleaved
repeat until something else shows upmanyTill, until, separatedUntil
repeat but throw the values awayskip, skipMany, skipMany1
reduce the repetitions as I gofold, fold1, foldRight, foldRight1

Choosing and branching

I want to…Reach for
take the first alternative that matcheschoice
succeed either wayoptional, optionalConsume, recover, flag
succeed only when something doesn't matchnot
gate on a boolean I already haveguard, unless, when
look ahead without consuming inputpeek

Sequencing and pulling values out

I want to…Reach for
run parsers in order and keep every resultsequence
keep only one side of a pairleft, right
keep specific positions of a sequencefirst, last, nth, pick
drop the delimiters, keep the middleinner, outer
glue string results togetherconcat, fuse, join
match content inside delimiterssurrounded, parenthesized, braced, bracketed, quoted, padded

Shaping the result

I want to…Reach for
transform the parsed valuemap, pipe
replace it with a constantvalue, pure, consume
build a typed AST nodenode
reject a value after parsing itvalidate
choose the next parser from the last valuebind
tidy up arrays inside a mappick, filter, flatten, shift, pop, spread

Grammars, recursion and errors

I want to…Reach for
let rules reference each other by namegrammar
refer to a parser defined further downlazy
cache results and avoid exponential blowupmemoize
attach a readable error messagelabel
parse without throwing, with line and columnparse
turn an offset into a line and columnlocate
render a failure with a caret under itformat, ParseError
record what a hand-written parser expectedcontext, record, union
parse infix operators with precedencechainLeft, chainLeft1, chainRight, chainRight1
parse prefix / postfix operatorsprefix, postfix
handle both outcomes of a Resultmatch

Core concepts

The Parser type

A 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.

The Result type

type 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'] }

Backtracking is free

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.

Writing one by hand

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);
    });

Grammars

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

Error reporting

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 '!'

More examples

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'] }

API index

One line per export. Every name links to its full description and runnable example in the reference pages under doc/api.

Core — unitas

Types, constructors and the grammar runner. 17 exports — full reference →

contextCreates a fresh context. run and parse make one per call.
createCreates a parser from a parser function.
failureCreates a failed result at an offset and notes it in the parse context.
formatRenders a parse failure as a source excerpt with a caret under the offset.
grammarCreates a recursive grammar where rules can reference each other.
labelReplaces the expectations of a parser with a single description.
lazyDefers parser creation, useful for recursive grammars.
locateConverts a character offset into a 1-based line and column.
matchPattern matching on a Result to handle success and failure cases.
memoizeMemoizes a parser so each offset is parsed at most once (packrat caching).
parseRuns a parser over the whole input without throwing.
ParseErrorError thrown by run when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset.
recordNotes what was expected at an offset, keeping only the furthest one seen.
rejectCreates a failed result from an expectation list that is already allocated, avoiding a fresh array on every failure.
runRuns a parser over the whole input and returns the value.
successCreates a successful result with a value and the offset reached in the input.
unionAdds the entries of b to a, returning a itself when it already covers them.

Terminals — unitas/terminals

Factories that match the input string directly. 12 exports — full reference →

charParse a specific character.
charOfParse any character from a set.
noneOfParse any character not in the set.
oneOfParse one string from a set of strings (longest match wins).
regexParse with a regular expression.
satisfyParse a character satisfying a predicate.
stringParse a specific string.
stringOfParse first character that exists in string (like charOf but for a string).
takeTake n characters.
takeWhileTakes characters while the predicate returns true.
tokenParses a string as a token, skipping trailing whitespace.
wordParses a specific word and ensures it is not followed by word characters.

Primitives — unitas/primitives

Ready-made parsers for the usual suspects. 33 exports — full reference →

alphaNumParse a single alphanumeric character.
alphaNumsParse one or more alphanumeric characters as a string.
anyCharParse any single character.
boolParse a boolean literal.
crlfParse CRLF line ending.
digitParse a single digit and return as number.
digitsParse one or more digits and return as a number.
eofMatch the end of the input.
eolParse end of line (\n, \r\n, or EOF).
floatParse a floating point number.
hexDigitParse a single hexadecimal digit.
hexDigitsParse one or more hex digits as a string.
identifierParse an identifier — starts with letter or underscore, no leading digit, no hyphen.
integerParse a signed integer.
letterParse a single letter.
lettersParse one or more letters as a string.
lineParse until end of line.
literalParse a word-like value including hyphens.
lowercaseParse a single lowercase letter.
lowercasesParse one or more lowercase letters as a string.
nlParse a newline character.
numberParse an integer or float.
octDigitParse a single octal digit.
octDigitsParse one or more octal digits as a string.
positionGet the current offset into the input, without consuming anything.
restConsume and return everything left in the input.
spaceParse a single space character.
spacesParse one or more spaces as a string.
tabParse tab character.
uppercaseParses a single uppercase letter.
uppercasesParse one or more uppercase letters as a string.
whitespaceParses a single whitespace character.
whitespacesParse one or more whitespaces as a string.

Combinators — unitas/combinators

Take parsers, return a new parser. 64 exports — full reference →

bindChain parsers where the second parser depends on the first result.
bracedParse content surrounded by braces.
bracketedParse content surrounded by brackets.
chainLeftChain left-associative operations (right-to-left for same precedence).
chainLeft1Chain left-associative operations (fails on empty input).
chainRightChain right-associative operations.
chainRight1Chain right-associative operations (fails on empty input).
choiceTry each parser in order, return first success.
concatJoin string array parser result into a single string.
consumeConsume input but discard the result (return null).
endByZero or more items separated and ending with terminator.
endBy1One or more items separated and ending with terminator.
exactlyParse exactly n occurrences.
firstExtract the first element from a parser result array.
flagReturn true if parser succeeds, false otherwise.
foldFold zero or more occurrences into a single value.
fold1Fold one or more occurrences into a single value.
foldRightFold zero or more occurrences from the right into a single value.
foldRight1Fold one or more occurrences from the right into a single value.
fuseFuse multiple string parsers into a single one.
guardConditionally apply parser based on a condition.
innerExtract inner value from surrounded content (like inner of braced).
interleavedParse items separated by separators, keeping both in the result.
lastExtract the last element from a parser result array.
leftKeep only the left result from a sequence.
lexemeParser that consumes trailing whitespace.
manyZero or more occurrences (never fails).
many1One or more occurrences.
manyAtLeastParse at least n occurrences.
manyAtMostParse at most n occurrences (never fails).
manyBetweenParse between min and max occurrences.
manyTillParse zero or more until terminator matches.
mapTransform a parsed value through one or more functions.
nodeCreate a node from parser fields.
notSucceed if parser fails (without consuming input).
nthExtract the nth element from a parser result array.
optionalMake parser optional (return null on failure, without consuming input).
optionalConsumeConsume input if the parser matches, discarding the result.
optionalSeparatedByParse items separated by a separator, allowing empty slots.
outerExtract outer values from a sequence of 3 parsers (skip middle).
paddedParse content surrounded by optional whitespace.
parenthesizedParse content surrounded by parentheses.
peekLook ahead without consuming input.
postfixApply zero or more postfix operators to an atom.
prefixApply zero or more prefix operators to an atom.
pureAlways succeed with a value without consuming input.
quotedParse content surrounded by single or double quotes.
recoverUse fallback value when parser fails.
rightKeep only the right result from a sequence.
separatedByParse zero or more items separated by a separator.
separatedBy1Parse one or more items separated by a separator.
separatedEndByParse zero or more items separated by a separator, allowing a trailing one.
separatedEndBy1Parse one or more items separated by a separator, allowing a trailing one.
separatedUntilParse items separated by a separator, up to a terminator.
sequenceParse a sequence of parsers and return all results as an array.
skipSkip a parser n times.
skipManySkip zero or more occurrences (never fails, returns null).
skipMany1Skip one or more occurrences (fails if no matches).
surroundedParse content surrounded by delimiters.
unlessParse unless condition is true (inverse of guard).
untilParse zero or more until terminator matches, leaving the terminator unconsumed.
validateValidate parsed value with a predicate.
valueReplace parsed value with a constant.
whenBranch on a boolean parser result.

Utils — unitas/utils

Plain helpers for map callbacks. 8 exports — full reference →

filterExclude values from array.
flattenFlatten nested arrays.
joinJoin array elements into a string.
pickPick elements from an array by index.
pipePipe parser functions together.
popGet the last element of an array.
shiftGet the first element of an array.
spreadCollect spread arguments into an array.

Migrating from 0.2.x

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.x0.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 lengthposition → 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.

Contributing

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.

License

MIT © Oleg Kamlowski

Project health

Latest release

0.2.5 · May 3, 2026

Releases this year

8