LemmaScript formal verification for TypeScript
LemmaScript is a verification toolchain for TypeScript. Ordinary TypeScript
carries //@ specification comments; the lsc CLI generates formal
artefacts that a backend prover (Dafny, or Lean via Velvet/Loom) checks. The
annotations are comments — invisible to tsc, bundlers, and the runtime — and
the annotated TypeScript source remains the shipped production code.
LemmaScript verifies a formal model of that source; proofs apply only within
the supported TypeScript fragment and documented modelling assumptions. See
references/annotations.md for semantic limits:
number uses mathematical-integer rather than IEEE-754 semantics, Map/Record
values can differ at runtime, lifted method calls lose JavaScript short-circuit
behaviour, and cross-file calls are axiomatized rather than body-verified.
UI/I/O/auth/clock/adapter code is trusted unless separately covered. Think
"Verus is to Rust as
LemmaScript is to TypeScript".
Where fast-check samples inputs and can only find bugs, LemmaScript proves
properties for every input. It costs far more effort per property, so reserve
it for load-bearing logic; use the fast-check skill for the broad regression
net beneath the proofs.
When to apply
Apply when a property must hold unconditionally and a violation is severe:
invariant preservation across a state machine's actions, conservation ("money
never leaks"), soundness and completeness of a decision procedure, security
predicates (path-traversal containment, open-redirect exclusion,
permission-gate correctness), parser conservation, or bounded-resource
guarantees (no overbooking, rate-limit bounds).
Do not apply to code outside the supported fragment (heavy async,
this-dispatch, closures over mutable state, regex, real I/O), to
float-sensitive numerics (the model treats number as a mathematical integer),
or when a sampled property-based test gives sufficient confidence — proofs are
expensive to write and to maintain.
Installation and prerequisites
Node.js ≥ 18. Dafny ≥ 4.x for the primary backend; elan plus the Loom/Velvet
forks for the Lean backend.
npm install lemmascript@0.5.22
For brownfield work, clone LemmaScript as a sibling of the target project
and invoke it from source (npx tsx ../LemmaScript/tools/src/lsc.ts ...) — it
is a tech preview, and the cleanest fix for a gap is sometimes in the toolchain
itself. The midspiral/hono-lemmascript repository is a complete worked
example with annotations, generated Dafny, proofs, and CI.
Core concepts
Annotate the function, then generate and verify:
export function firstIndexOf(arr: number[], target: number): number {
//@ verify
//@ requires arr.length > 0
//@ ensures \result >= -1 && \result < arr.length
//@ ensures \result === -1 ==> forall(k: nat, k < arr.length ==> arr[k] !== target)
//@ ensures \result >= 0 ==> arr[\result] === target
//@ ensures \result >= 0 ==> forall(k: nat, k < \result ==> arr[k] !== target)
let i = 0;
while (i < arr.length) {
//@ invariant 0 <= i && i <= arr.length
//@ invariant forall(k: nat, k < i ==> arr[k] !== target)
//@ decreases arr.length - i
if (arr[i] === target) return i;
i = i + 1;
}
return -1;
}
npx --yes --package=lemmascript@0.5.22 -- lsc gen --backend=dafny src/find.ts # generate artefacts
npx --yes --package=lemmascript@0.5.22 -- lsc check --backend=dafny src/find.ts # generate + verify
npx --yes --package=lemmascript@0.5.22 -- lsc regen --backend=dafny src/find.ts # regenerate, 3-way merge
Key pieces:
//@ verify opts a function in; once any function in a file has it,
only marked functions are extracted.
//@ requires / //@ ensures are the contract; \result names the
return value; ==> is implication; forall(k, P) / exists(k, P) quantify.
//@ invariant and //@ decreases go at the top of the loop body.
- Pure functions (no loops, no mutation) are callable from other
functions' specs;
//@ pure forces the classification.
- Unmodellable calls are handled by
//@ extern (deterministic
axiom), //@ havoc (nondeterministic value), or file-level //@ autohavoc
(abstract everything unmodellable, soundly).
The complete annotation surface, the spec expression language, and the semantic
gotcha list live in references/annotations.md.
The edit loop (Dafny backend)
lsc gen produces two files beside the source: foo.dfy.gen (always
regeneratable — never edit) and foo.dfy (source of truth — add helper lemmas,
ghost predicates, assert nudges here). The diff between them must be
additions only; lsc check enforces this. After editing the TypeScript, run
regen (never delete and gen fresh — that discards every proof). When Dafny
complains, the fix belongs either in the .ts (tighten requires, weaken
ensures, add invariant/decreases) or in the .dfy (helper lemma, ghost
predicate, assert).
The full workflow — backend choice, Lean's four-file scheme, proof debugging
flags, CI wiring, and brownfield strategy — lives in
references/workflow.md.
Anti-patterns
- Reaching for
//@ assume to silence a failure. It tells the
prover to trust the obligation unconditionally; the proof stops meaning
anything. Restructure, or prove a helper lemma. Its one sanctioned use is
constraining a deliberately havoced value.
- Refactoring production code "for clarity" during brownfield
verification. In-place verification is the point; an unchanged diff is the
evidence the verified code is the shipped code.
- Editing
.dfy.gen. It is regenerated; changes vanish. Edit the
.dfy.
- Deleting
.dfy files to "start clean". Proof work lives there;
regen's three-way merge preserves it.
- Verifying a parallel model instead of the real function. Prefer
in-place annotation; where a type can't be imported, shadow it with
//@ declare-type so the actual function stays the proof target.
- Proving what the spec doesn't say. Write the
//@ contract
intent line and check the ensures actually expresses it — a theorem about
the wrong predicate verifies happily.
Project integration
- State the trust boundary in the README: which functions are
proved, and which surrounding layers (UI, I/O, auth, clock) are trusted. Case
studies do this plainly; follow suit.
- Track verified files in
LemmaScript-files.txt (one file per
line, optional per-file timeout and flags) so tools/check.sh and CI verify
them; copy hono-lemmascript's GitHub Actions workflow as the template.
- Start small in brownfield code: pure helpers, predicates,
parsers without I/O. Grow towards the invariant-bearing core.
- Keep fast-check properties alongside proofs. They run in the
inner loop in milliseconds, catch spec regressions before a prover run, and
cover the unverified boundary code.
- Land toolchain fixes separately. When the tech preview needs a
patch (unsupported method, missed narrowing), fix
LemmaScript/tools/src/ in
its own PR.
Hard-won lessons
- The spec is the product. Provers check the written specification, not
the intended one. Review
ensures clauses as adversarially as code.
number is a mathematical integer in the model (floats become
real; overflow at 2^53 is out of scope). Do not verify float-sensitive
numerics; do flag integer-encoding overflow risks — a case study's
injectivity proof surfaced a real ≥1000-votes overflow in an existing
encoding.
- Missing invariants live in the TypeScript. The LLM/prover loop
can supply tactics and lemmas in the
.dfy, but a missing //@ invariant
must be added in the source and regenerated.
- Refinement scales. For loop-heavy code, prove the method equals
a pure recursive spec (
result == range_spec(...)), then prove properties of
the spec — every property transfers automatically.
- Verification conditions are cheap to split.
dafny verify --filter-symbol=... and --isolate-assertions turn one opaque
timeout into named, tractable obligations.
References
- LemmaScript repository
— SPEC.md, DESIGN.md, GETTING_STARTED.md, AGENTS.md, examples.
- Announcement post
and lemmascript.com.
- Worked case studies:
hono-lemmascript
(brownfield, CVE-driven),
clear-split-lemmascript
(greenfield, dual-backend),
collab-todo-lemmascript
(verified domain model behind a React app).
references/annotations.md for the
annotation surface, spec language, and gotchas.
references/workflow.md for the edit
loop, backends, CI, and brownfield strategy.
- Sampled-input testing as the complementary adversary lives in
../fast-check/SKILL.md.
1---2name: lemmascript3description: >4 Verify TypeScript formally with LemmaScript: write `//@` specification5 annotations in ordinary TypeScript, generate Dafny or Lean artefacts6 with `lsc`, and discharge proof obligations so properties hold for all7 inputs, not just sampled ones. Trigger whenever the user mentions8 LemmaScript, lsc, formal verification or model checking of TypeScript9 or JavaScript, proving a TypeScript function correct, `//@ requires`10 / `//@ ensures` annotations, .dfy.gen files, or wants machine-checked11 guarantees (invariant preservation, conservation, soundness,12 completeness) for TypeScript code. Covers LemmaScript 0.5.x (tech13 preview) with the Dafny and Lean backends.14---1516# LemmaScript formal verification for TypeScript1718LemmaScript is a verification toolchain for TypeScript. Ordinary TypeScript19carries `//@` specification comments; the `lsc` CLI generates formal20artefacts that a backend prover (Dafny, or Lean via Velvet/Loom) checks. The21annotations are comments — invisible to tsc, bundlers, and the runtime — and22the annotated TypeScript source remains the shipped production code.23LemmaScript verifies a formal model of that source; proofs apply only within24the supported TypeScript fragment and documented modelling assumptions. See25[`references/annotations.md`](references/annotations.md) for semantic limits:26`number` uses mathematical-integer rather than IEEE-754 semantics, Map/Record27values can differ at runtime, lifted method calls lose JavaScript short-circuit28behaviour, and cross-file calls are axiomatized rather than body-verified.29UI/I/O/auth/clock/adapter code is trusted unless separately covered. Think30"Verus is to Rust as31LemmaScript is to TypeScript".3233Where fast-check samples inputs and can only find bugs, LemmaScript proves34properties for every input. It costs far more effort per property, so reserve35it for load-bearing logic; use the `fast-check` skill for the broad regression36net beneath the proofs.3738## When to apply3940Apply when a property must hold unconditionally and a violation is severe:41invariant preservation across a state machine's actions, conservation ("money42never leaks"), soundness and completeness of a decision procedure, security43predicates (path-traversal containment, open-redirect exclusion,44permission-gate correctness), parser conservation, or bounded-resource45guarantees (no overbooking, rate-limit bounds).4647Do not apply to code outside the supported fragment (heavy `async`,48`this`-dispatch, closures over mutable state, regex, real I/O), to49float-sensitive numerics (the model treats `number` as a mathematical integer),50or when a sampled property-based test gives sufficient confidence — proofs are51expensive to write and to maintain.5253## Installation and prerequisites5455Node.js ≥ 18. Dafny ≥ 4.x for the primary backend; `elan` plus the Loom/Velvet56forks for the Lean backend.5758```sh59npm install lemmascript@0.5.2260```6162For brownfield work, clone LemmaScript as a **sibling** of the target project63and invoke it from source (`npx tsx ../LemmaScript/tools/src/lsc.ts ...`) — it64is a tech preview, and the cleanest fix for a gap is sometimes in the toolchain65itself. The `midspiral/hono-lemmascript` repository is a complete worked66example with annotations, generated Dafny, proofs, and CI.6768## Core concepts6970Annotate the function, then generate and verify:7172```typescript73export function firstIndexOf(arr: number[], target: number): number {74 //@ verify75 //@ requires arr.length > 076 //@ ensures \result >= -1 && \result < arr.length77 //@ ensures \result === -1 ==> forall(k: nat, k < arr.length ==> arr[k] !== target)78 //@ ensures \result >= 0 ==> arr[\result] === target79 //@ ensures \result >= 0 ==> forall(k: nat, k < \result ==> arr[k] !== target)80 let i = 0;81 while (i < arr.length) {82 //@ invariant 0 <= i && i <= arr.length83 //@ invariant forall(k: nat, k < i ==> arr[k] !== target)84 //@ decreases arr.length - i85 if (arr[i] === target) return i;86 i = i + 1;87 }88 return -1;89}90```9192```sh93npx --yes --package=lemmascript@0.5.22 -- lsc gen --backend=dafny src/find.ts # generate artefacts94npx --yes --package=lemmascript@0.5.22 -- lsc check --backend=dafny src/find.ts # generate + verify95npx --yes --package=lemmascript@0.5.22 -- lsc regen --backend=dafny src/find.ts # regenerate, 3-way merge96```9798Key pieces:99100- `//@ verify` opts a function in; once any function in a file has it,101 only marked functions are extracted.102- `//@ requires` / `//@ ensures` are the contract; `\result` names the103 return value; `==>` is implication; `forall(k, P)` / `exists(k, P)` quantify.104- `//@ invariant` and `//@ decreases` go at the top of the loop body.105- Pure functions (no loops, no mutation) are callable from other106 functions' specs; `//@ pure` forces the classification.107- Unmodellable calls are handled by `//@ extern` (deterministic108 axiom), `//@ havoc` (nondeterministic value), or file-level `//@ autohavoc`109 (abstract everything unmodellable, soundly).110111The complete annotation surface, the spec expression language, and the semantic112gotcha list live in [`references/annotations.md`](references/annotations.md).113114## The edit loop (Dafny backend)115116`lsc gen` produces two files beside the source: `foo.dfy.gen` (always117regeneratable — never edit) and `foo.dfy` (source of truth — add helper lemmas,118ghost predicates, `assert` nudges here). The diff between them must be119**additions only**; `lsc check` enforces this. After editing the TypeScript, run120`regen` (never delete and `gen` fresh — that discards every proof). When Dafny121complains, the fix belongs either in the `.ts` (tighten `requires`, weaken122`ensures`, add `invariant`/`decreases`) or in the `.dfy` (helper lemma, ghost123predicate, assert).124125The full workflow — backend choice, Lean's four-file scheme, proof debugging126flags, CI wiring, and brownfield strategy — lives in127[`references/workflow.md`](references/workflow.md).128129## Anti-patterns130131- **Reaching for `//@ assume` to silence a failure.** It tells the132 prover to trust the obligation unconditionally; the proof stops meaning133 anything. Restructure, or prove a helper lemma. Its one sanctioned use is134 constraining a deliberately `havoc`ed value.135- **Refactoring production code "for clarity" during brownfield136 verification.** In-place verification is the point; an unchanged diff is the137 evidence the verified code is the shipped code.138- **Editing `.dfy.gen`.** It is regenerated; changes vanish. Edit the139 `.dfy`.140- **Deleting `.dfy` files to "start clean".** Proof work lives there;141 `regen`'s three-way merge preserves it.142- **Verifying a parallel model instead of the real function.** Prefer143 in-place annotation; where a type can't be imported, shadow it with144 `//@ declare-type` so the actual function stays the proof target.145- **Proving what the spec doesn't say.** Write the `//@ contract`146 intent line and check the `ensures` actually expresses it — a theorem about147 the wrong predicate verifies happily.148149## Project integration150151- **State the trust boundary in the README**: which functions are152 proved, and which surrounding layers (UI, I/O, auth, clock) are trusted. Case153 studies do this plainly; follow suit.154- **Track verified files in `LemmaScript-files.txt`** (one file per155 line, optional per-file timeout and flags) so `tools/check.sh` and CI verify156 them; copy hono-lemmascript's GitHub Actions workflow as the template.157- **Start small in brownfield code**: pure helpers, predicates,158 parsers without I/O. Grow towards the invariant-bearing core.159- **Keep fast-check properties alongside proofs.** They run in the160 inner loop in milliseconds, catch spec regressions before a prover run, and161 cover the unverified boundary code.162- **Land toolchain fixes separately.** When the tech preview needs a163 patch (unsupported method, missed narrowing), fix `LemmaScript/tools/src/` in164 its own PR.165166## Hard-won lessons167168- **The spec is the product.** Provers check the written specification, not169 the intended one. Review `ensures` clauses as adversarially as code.170- **`number` is a mathematical integer** in the model (floats become171 `real`; overflow at 2^53 is out of scope). Do not verify float-sensitive172 numerics; do flag integer-encoding overflow risks — a case study's173 injectivity proof surfaced a real ≥1000-votes overflow in an existing174 encoding.175- **Missing invariants live in the TypeScript.** The LLM/prover loop176 can supply tactics and lemmas in the `.dfy`, but a missing `//@ invariant`177 must be added in the source and regenerated.178- **Refinement scales.** For loop-heavy code, prove the method equals179 a pure recursive spec (`result == range_spec(...)`), then prove properties of180 the spec — every property transfers automatically.181- **Verification conditions are cheap to split.**182 `dafny verify --filter-symbol=...` and `--isolate-assertions` turn one opaque183 timeout into named, tractable obligations.184185## References186187- [LemmaScript repository](https://github.com/midspiral/LemmaScript)188 — SPEC.md, DESIGN.md, GETTING_STARTED.md, AGENTS.md, examples.189- [Announcement post](https://midspiral.com/blog/lemmascript-a-verification-toolchain-for-typescript/)190 and [lemmascript.com](https://lemmascript.com/).191- Worked case studies:192 [hono-lemmascript](https://github.com/midspiral/hono-lemmascript)193 (brownfield, CVE-driven),194 [clear-split-lemmascript](https://github.com/midspiral/clear-split-lemmascript)195 (greenfield, dual-backend),196 [collab-todo-lemmascript](https://github.com/midspiral/collab-todo-lemmascript)197 (verified domain model behind a React app).198- [`references/annotations.md`](references/annotations.md) for the199 annotation surface, spec language, and gotchas.200- [`references/workflow.md`](references/workflow.md) for the edit201 loop, backends, CI, and brownfield strategy.202- Sampled-input testing as the complementary adversary lives in203 [`../fast-check/SKILL.md`](../fast-check/SKILL.md).