TypeScript / WASM
hyperlark ships as a WebAssembly module with a typed TypeScript surface. It runs
on Node (the default entry) and in the browser / bundlers / Deno (via the
hyperlark/web entry).
Install
Section titled “Install”npm install hyperlark@betaThe beta publishes under the beta dist-tag, not latest, so the @beta is
required — a plain npm install hyperlark resolves latest and fails.
Three entries ship in the one package:
| Entry | Loads the wasm | Compiles .lark source |
|---|---|---|
hyperlark | synchronously (Node only) | new Lark(source) and Lark.fromJSON |
hyperlark/web | asynchronously, via await init() | new Lark(source) and Lark.fromJSON |
hyperlark/slim | synchronously (Node only) | Lark.fromJSON only |
Reach for slim when the grammar is compiled ahead of time and download size
matters: it drops the grammar compiler, the heaviest component in the module,
and cannot compile .lark source.
Your first parser (Node)
Section titled “Your first parser (Node)”import { Lark } from "hyperlark";
const parser = new Lark(`start: "hello" NAMENAME: /\\w+/%ignore " "`);
const tree = parser.parse("hello world");console.log(tree);In the browser / bundlers / Deno
Section titled “In the browser / bundlers / Deno”Use the async hyperlark/web entry — the same .wasm, with a browser-friendly
loader:
import init from "hyperlark/web";
const { Lark } = await init();const parser = new Lark(`start: "hello" NAME\nNAME: /\\w+/\n%ignore " "`);console.log(parser.parse("hello world"));Only the loading differs from Node — the Lark / Tree / Token / error
surface is identical.
init()is idempotent: call andawaitit once before the first use.init(input?)accepts an optionalURL | Response | BufferSource | WebAssembly.Module, for a CDN URL, pre-fetched bytes, or an edge runtime. Omit it to auto-fetchhyperlark_wasm_bg.wasmrelative to the module (import.meta.url— Vite and webpack 5 rewrite this to an emitted asset automatically).- Serve the
.wasmwith theapplication/wasmMIME type for streaming compile. Dev servers do this; the glue falls back to non-streaming otherwise. - The pure-JS toolkit needs no wasm:
Transformer, the visitor family, the tree free functions andmapLoaderare static named exports you can import withoutinit(). - The disk-backed
fsLoaderis Node-only and absent fromhyperlark/web; usemapLoader, or any callback, for%import.
Parsing
Section titled “Parsing”new Lark(source, options?) compiles a grammar (not on slim);
Lark.fromJSON(json, options?) loads a pre-compiled one (every entry).
The default parser is earley, matching Lark — including on the fromJSON
path. Pass parser: "lalr" to opt into the LALR engine, which the reduce-time
seams require (transformer, parseStream, on_error). A constructor-level
transformer: makes the reduce-time fold the instance default — Lark’s embedded
transformer= — and requires parser: "lalr", throwing ConfigurationError at
construction otherwise, exactly as Lark does.
Three parse entry points share one internal buffered reduce-drain:
-
parse(text, opts?)—opts = { transformer?, start?, batch_size?, on_error? }. With notransformerit returns the plain{ data, children, meta? }tree. With atransformer— a plain object whose method names are rule display names, or__default__(name, children)— it folds the parse to a host value at the reduce seam, crossing into JS once perbatch_size(default 128). The folded result is batch-invariant; set1for exact reduce/callback interleaving when the transformer has side effects.on_erroris the LALR cold-path(err) => booleanrecovery callback, on the tree path only.Behind a custom
lexerthere is no reduce seam, so the fold runs over the finished tree instead: same result, but the callbacks fire after the parse andbatch_sizehas no effect. -
parseStream(text, onSubtree, opts)—opts = { on, batch_size? }.on(a rule display name, or an array of them) selects which reductions stream; each completed subtree is handed toonSubtree(node)as it reduces (post-order). Returns the emitted count, andonSubtreereturningfalsestops the parse early.batch_sizedefaults to 1 here — prompt delivery and minimal early-stop run-ahead are the point of this API. -
parseHandle(text)— a retained-arena handle plusTreeCursors, with no eager copy-out. Cursors are iterable —for (const pos of handle.findData("pair"))— and self-free at loop end, on exhaustion orbreak;TreeCursor.free()is idempotent. The handle offers query sugar over thecursor*factories:findData(name, start?)/findToken(name, start?)(name first, like the toolkit free functions) andsubtrees/leaves/topdown/postorder, each defaultingstarttorootPos().
Plus lex(text, dontIgnore?), getTerminal(name), starts(),
resolvedLexer(), tokenNames(), ruleNames() and propagatePositions().
Constructor options
Section titled “Constructor options”The keys this binding reads:
start, parser, lexer, ambiguity, priority, propagate_positions,
maybe_placeholders, keep_all_tokens, g_regex_flags, indenter,
import_loader, transformer, translate_python_regex.
Anything else fails loudly at construction rather than doing nothing — the
Lark options not implemented here (cache, cache_grammar, strict,
use_bytes, regex, debug, edit_terminals, lexer_callbacks, postlex,
tree_class, import_paths, source_path) and misspellings alike:
ConfigurationError: Unknown options: cache.
A key it does read also throws on a value it cannot use — a wrong type, or a
name outside the set it accepts — rather than falling back to a default. So
{propagate_positions: "yes"}, {g_regex_flags: 2.5} and {start: [42]} are
errors, not quiet no-ops.
The finer rules of that check:
- A key present but set to
undefinedcounts as absent, so spreading an object with optional fields is safe. - Only the object’s own string-keyed properties count, enumerable or not — including those of a null-prototype dictionary. Nothing is looked up a prototype chain.
- A symbol key cannot name a Lark option, so one is ignored rather than refused.
Per-call options keep Lark’s spelling where Lark has the kwarg:
parse(text, { on_error }) mirrors Lark’s parse(text, on_error=...).
onError and batchSize are accepted aliases of on_error and batch_size.
translate_python_regex is read, but only false is accepted — anything
else throws a ConfigurationError saying so, rather than the generic
unknown-option one. The option is experimental and reachable from Rust and
Python only. A grammar saved with it on still loads through Lark.fromJSON,
which carries the dialect.
Errors
Section titled “Errors”The error constructors are named module exports — import { UnexpectedToken } from "hyperlark" — so instanceof works directly. On the web entry they live
behind await init(), which is the deliberate Node/web split;
errorClasses() returns the same constructors for dynamic access.
The hierarchy: LarkError > {ConfigurationError, GrammarError, ParseError > UnexpectedInput > {UnexpectedToken, UnexpectedCharacters, UnexpectedEOF}}, DedentError. They are thrown on both the compile and the parse path.
See the errors guide for what each one carries.
%import loader
Section titled “%import loader”The loader is synchronous — Lark’s compile has no suspend point:
(base: string | null, path: string) => { path: string, text: string } | nullReturning null falls through to the embedded stdlib (common, unicode, …).
fsLoader(rootDir) and mapLoader(sources) are ready-made. Prefetch async
sources before construction.
Custom lexer contract
Section titled “Custom lexer contract”options.lexer takes a class (new Cls(conf)), a factory
((conf) => lexer), or a ready instance, and is valid under both parsers.
See the lexers guide for the shape; the contract it promises:
lex(input, parserState)is called lazily once per parse, and the returned iterator or iterable (a generator works) is pulled one token at a time, interleaved with the parser.parserStateis live:{ position }under LALR (the current state index), aSetof acceptable terminal names under Earley. It is mutated per pull.- Tokens are plain objects:
type(the terminal name), a stringvalue, plus any of the six optional positions — the engines never recompute them. - The original yielded objects become the tree leaves, so extra fields
survive. Postlex synthetics (an indenter’s
_INDENT/_DEDENT) fall through to materialized tokens. conf(LexerConf) exposes the consumed subset —terminals(TerminalDef[]),ignore,g_regex_flags,terminals_by_name— and feedsBasicLexer(the exported wrap of the native raw scan,RawBasicLexer/RawLexCursor), so a custom lexer can decorate the built-in tokenization.- An exception thrown by the lexer escapes
parseas the same value (===identity), and is never routed intoon_error— matching Lark, whose recovery callback only seesUnexpectedToken/UnexpectedCharacters. - When a parse aborts mid-stream, the abandoned
lex()iterator’sreturn()is called, so generatorfinallyblocks run. - Sync only:
parseis sync. Note thatinstance.lex()uses the built-in scan regardless of a custom lexer, as in Lark, whoselex()builds aBasicLexerindependently.
Interactive parsing
Section titled “Interactive parsing”parseInteractive(text?, start?) seats a manually-driven parser — Lark’s
parse_interactive, LALR with the built-in lexer only. feedToken / feedNext
/ iterParse() / exhaustLexer advance it, accepts() / choices() /
pretty() inspect the live state, copy() forks an independent parser, and
feedEof() / resumeParse() finish it, returning the tree as a ParseHandle.
The parser owns its grammar reference, so it stays valid even if the Lark
handle is freed first; like every wasm-backed object it must be free()d.
iterParse() feeds each token before yielding it, where the Python binding
yields first and feeds after — so accepts() read inside the loop reports the
state after that token here, and before it there. Breaking out of the loop
strands the token already fed.
See the interactive guide for the walkthrough.
Next steps
Section titled “Next steps”- Grammar reference — the
.larkgrammar language. - Feature matrix — what each target supports.
- Alternatives — hyperlark vs chevrotain, peggy, nearley.
- API reference — the generated TypeDoc for this surface.