Skip to content

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

Terminal window
npm install hyperlark@beta

The 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:

EntryLoads the wasmCompiles .lark source
hyperlarksynchronously (Node only)new Lark(source) and Lark.fromJSON
hyperlark/webasynchronously, via await init()new Lark(source) and Lark.fromJSON
hyperlark/slimsynchronously (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.

import { Lark } from "hyperlark";
const parser = new Lark(`start: "hello" NAME
NAME: /\\w+/
%ignore " "`);
const tree = parser.parse("hello world");
console.log(tree);

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 and await it once before the first use.
  • init(input?) accepts an optional URL | Response | BufferSource | WebAssembly.Module, for a CDN URL, pre-fetched bytes, or an edge runtime. Omit it to auto-fetch hyperlark_wasm_bg.wasm relative to the module (import.meta.url — Vite and webpack 5 rewrite this to an emitted asset automatically).
  • Serve the .wasm with the application/wasm MIME 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 and mapLoader are static named exports you can import without init().
  • The disk-backed fsLoader is Node-only and absent from hyperlark/web; use mapLoader, or any callback, for %import.

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 no transformer it returns the plain { data, children, meta? } tree. With a transformer — 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 per batch_size (default 128). The folded result is batch-invariant; set 1 for exact reduce/callback interleaving when the transformer has side effects. on_error is the LALR cold-path (err) => boolean recovery callback, on the tree path only.

    Behind a custom lexer there is no reduce seam, so the fold runs over the finished tree instead: same result, but the callbacks fire after the parse and batch_size has 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 to onSubtree(node) as it reduces (post-order). Returns the emitted count, and onSubtree returning false stops the parse early. batch_size defaults to 1 here — prompt delivery and minimal early-stop run-ahead are the point of this API.

  • parseHandle(text) — a retained-arena handle plus TreeCursors, with no eager copy-out. Cursors are iterablefor (const pos of handle.findData("pair")) — and self-free at loop end, on exhaustion or break; TreeCursor.free() is idempotent. The handle offers query sugar over the cursor* factories: findData(name, start?) / findToken(name, start?) (name first, like the toolkit free functions) and subtrees / leaves / topdown / postorder, each defaulting start to rootPos().

Plus lex(text, dontIgnore?), getTerminal(name), starts(), resolvedLexer(), tokenNames(), ruleNames() and propagatePositions().

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 undefined counts 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.

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.

The loader is synchronous — Lark’s compile has no suspend point:

(base: string | null, path: string) => { path: string, text: string } | null

Returning null falls through to the embedded stdlib (common, unicode, …). fsLoader(rootDir) and mapLoader(sources) are ready-made. Prefetch async sources before construction.

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.
  • parserState is live: { position } under LALR (the current state index), a Set of acceptable terminal names under Earley. It is mutated per pull.
  • Tokens are plain objects: type (the terminal name), a string value, 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 feeds BasicLexer (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 parse as the same value (=== identity), and is never routed into on_error — matching Lark, whose recovery callback only sees UnexpectedToken / UnexpectedCharacters.
  • When a parse aborts mid-stream, the abandoned lex() iterator’s return() is called, so generator finally blocks run.
  • Sync only: parse is sync. Note that instance.lex() uses the built-in scan regardless of a custom lexer, as in Lark, whose lex() builds a BasicLexer independently.

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.