GitHub - JacobAsmuth/qed: A formally-verified web frontend framework in Lean 4

14 min read Original article ↗

A formally verified web frontend framework in Lean 4.

Frontend bugs are where the ones you ship actually live. The missing case in a reducer. The render that throws on an empty list. The "this can't happen" that happens in production. Every framework asks you to trust your code is right, and hands you a type checker and a test suite to hope along with you.

Qed makes a different bet. You write your app in Lean, a proof assistant. The same kernel mathematicians use to check proofs checks your frontend.

qed build transpiles your app and the whole verified framework straight to plain JavaScript. No emscripten, no WASM, no special runtime. The output is a handful of .mjs files you serve anywhere. The proofs that pass qed check describe the JavaScript that actually runs. If you've written React, you already know the shape: components with state and props, written in JSX. Underneath, every component desugars to Elm's architecture, a typed message and a pure reducer. That's what makes the proofs possible.

curl -sSfL https://raw.githubusercontent.com/JacobAsmuth/qed/main/install.sh | sh
qed new myapp && cd myapp && qed dev      # → http://localhost:8000, live-reloading

A proof assistant, in my frontend?

Two guarantees fall out before you write a single proof.

Your app can't crash at runtime. State transitions and views are ordinary total Lean functions. A missing case in a match, or a render that might not terminate, isn't a warning you can mute. It's a build error. The broken code never reaches a user, because it never reaches dist/.

You can state a fact about your app and let the kernel prove it. This is the part with no analogue in a normal framework. Here's a complete app, a counter whose count can never go negative; Qed.run Counter.app is the whole browser entry.

component Counter where
  state count : Int := 0
  view =>
    <div class="counter">
      <button onClick={set count (if 0 < count then count - 1 else count)}>−</button>
      <span class="count">{count}</span>
      <button onClick={set count (count + 1)}>+</button>
      <button onClick={set count 0}>reset</button>
    </div>

invariant counterSafe : (fun s => 0 ≤ s.count) preserved_by Counter.update

State lives next to the view that uses it, and set is the only way to change it. But a handler is not a closure mutating a cell: each set site becomes a constructor of a generated message type, and a generated pure reducer (Counter.update) interprets it. That's Elm's architecture, derived from the component, and it's what the invariant is stated over.

So the last line isn't a test, and you don't write its proof. It checks that every handler leaves the count non-negative, and the build succeeds only if that holds for all of them. Delete the if 0 < count guard and the build fails naming the handler that broke the invariant (case `set_count` still needs: 0 ≤ m.count - 1).

Those glyphs aren't something you hunt for on the keyboard. In a Lean editor you type \le and it turns into as you go, the same way \ge \to \and \or give you ≥ → ∧ ∨. The comparisons and the arrow also take plain ASCII, so <=, >=, and -> work as written for , , and .

One way to write a view

You write the view in JSX, with plain Lean in the braces: if, .map, string interpolation, your own helpers. No memo, no signals, no useState to place. The framework decides, per subtree, how to apply each change. A model value that changed is written straight at its node. A change of shape (rows added, removed, reordered, a branch flipped) reconciles through the diff. Change the counter and that one text node is rewritten. Nothing else.

The proof is what makes deciding for you safe. The value-update path is proven to produce the same DOM as a full re-render, and the diff is proven correct. The cheap update can never drift from re-rendering everything. The stale row or dropped update that a missing key or a misplaced memo causes elsewhere isn't something you can hit here.

Is the thing that runs the thing you proved?

A verified framework is worthless if the bytes in the browser aren't the bytes you proved things about. Every "verified" claim has to answer this. Qed's answer: there is no hand-written runtime to diverge from the proofs. qed build runs the Lean compiler's IR through a transpiler (qedjs) that emits JavaScript for your app, the whole framework, and the driver that runs them.

Lean app (components, Model/Msg/update, invariants; proofs auto-discharged)
   │  lake build              (the kernel checks every proof)
   ▼
qedjs  (transpiles the Lean to JavaScript: your app + the Qed framework + the driver)
   ▼
dist/app.mjs  +  runtime/qed_rt.mjs   (a small library of the Lean primitives it uses)
   ▼
runtime/qed_dom.mjs + qed_host.mjs    (the only hand-written JS: the DOM calls and the
                  event wiring; everything else is your verified Lean, as JavaScript)

The only JavaScript a human wrote is the thin boundary at the bottom: the actual DOM calls and event delegation. Everything above it is verified Lean. test/js_gate_test.mjs runs the same probes through native Lean and through the transpiled JS and asserts they compute exactly the same thing: render, diff, arithmetic, JSON, routing.

Schema: forms and JSON, one declaration

A form and an API payload are usually the same data, validated twice. In Qed you declare the data once, rules included, and schema generates both sides.

abbrev NonEmpty (s : String) : Prop := s.length ≥ 1
abbrev Year (n : Nat) : Prop := 1 ≤ n ∧ n ≤ 2026

schema Book where
  id      : Codec.text.jsonOnly                    -- rides the JSON, never shown in the form
  title   : Codec.text.refine NonEmpty
  year    : Codec.nat.refine Year                  -- parsed to a Nat first, then checked
  inPrint : Codec.checkbox
  blurb   : Codec.text                             -- unrefined, stays a bare `String`
  tags    : Codec.json (List String)               -- nested data rides the JSON only

That one declaration yields the Book type, the form (Book.formView, whose submit button stays disabled until every field validates), and the JSON codec (Book.decode / Book.encode). A rule like Year is enforced in both directions by the same proof: the form won't submit an out-of-range year, and decode rejects one arriving over the wire. So a Book you hold is valid; an invalid one can't be constructed. And Json.parse itself is total, so bad input is an .error value, never an exception.

Routing and HTTP

router declares your pages, and from that one table it proves routing round-trips: any route you print is one you can parse back into the exact route that produced it. That proof is why linkTo takes a route value, not a string. The link below can only point at a page that exists, so a mistyped or never-declared route is a compile error, not a broken link you ship.

router R where
  home => ""
  user (name : String) => "users"
  post (id : Nat)       => "posts"
  * notFound => "404"                    -- where any unmatched URL lands

def app : App Model Msg :=
  ui init transition (onRoute := Msg.routed) fun m =>
    <form onSubmit={.submit}>
      <input value={m.query} onInput={.typeQuery}/>
      {linkTo (R.user "ada") [] "ada"}   -- a real route, checked at compile time
    </form>

A parameter rides the URL as one segment. A String goes through verbatim; a Nat or Int is decoded when the URL parses, so /posts/abc never reaches your code as a post. The proof covers the links your app builds, not what someone types in the address bar, so a URL that matches nothing still has to resolve to something. Mark a route with * and unmatched URLs land there; leave it off and they fall back to your first route. onRoute hands your transition the parsed route, and Cmd.getJson does the fetch and the decode in one step.

Effects

Your update never calls fetch. To touch the outside world it returns a Cmd, a value describing the effect; the driver performs it and delivers the result back as ordinary messages. That's what keeps update a pure function, the thing the proofs are about.

Here's the entire logic of a chat app that streams an LLM's reply token by token. Cmd.stream feeds each token back as a .chunk message, so to update, a streaming reply is just more messages arriving:

def transition (m : Model) : Msg → Model × Cmd Msg := steps
  | .typed s   => { m with draft := s }
  | .send      => (pushTurn m, .stream "/v1/chat/completions" (reqBody m.turns.pop) .chunk .done)
  | .chunk raw => { m with turns := appendLast m.turns (deltaOf raw) }
  | .done      => { m with pending := false }

(steps is how you write an effectful transition: an arm returns the next model, or a (model, cmd) pair.) The built-in commands cover what you usually reach for: HTTP, localStorage, clipboard, timers, WebSockets. For anything else, the ports escape hatch wires a real JS API in a few lines.

Components

Every piece of UI is the same declaration the counter at the top used. Here's a feed card. The like handler sets two fields in one message, and the two invariants are the card's contract, one over behavior, one over styling.

component Card where
  state id    : Nat        -- no defaults: the parent fills these in
  state likes : Int
  state liked : Bool
  key id                   -- which field identifies a card in a list
  view =>
    <button role="like"
      onClick={set liked (!liked), set likes (if liked then likes - 1 else likes + 1)}
      {if liked then likeOn else likeOff}>{s!"♥ {likes}"}</button>

invariant cardSafe   : (fun c => 0 ≤ c.likes ∧ (c.liked → 1 ≤ c.likes)) preserved_by Card.update
invariant cardStyled : roleHasOneOf "like" [likeOn, likeOff] holds_in Card.view

A handler is not a closure: each set compiles to an ordinary message with a named case, so invariants work on a component exactly as they do on the app model, and breaking one fails the build naming the handler (case set_liked_likes …).

A component is used as a JSX tag (capitalized, the React rule), and who owns its state is decided at the use site, not in how you write it. State the parent never reads, like an open editor or a half-typed draft, stays out of your model entirely. <Editor key="row-7"/> mounts a keyed instance the framework owns. Props seed it (<Editor text={r.text}/>), onEmit={…} receives its typed output, and registration is automatic. State the parent does read, the parent owns: the feed holds its cards in the model, binds each one with state={…}, and shows the list like any other data:

structure Model where
  cards : Array Card.State

inductive Msg | card (k : String) (msg : Card.Msg) | rank | dismiss (id : Nat)

def update (m : Model) : Msg → Model
  | .card k msg => { m with cards := Card.updateKeyed m.cards k msg }  -- one card, by key
  | .rank       => { m with cards := m.cards.sortBy (fun a b => a.likes ≥ b.likes) }
  | .dismiss id => { m with cards := m.cards.filter (·.id != id) }

def view (m : Model) : Html Msg :=
  <section class="feed">
    <button onClick={.rank}>Most liked</button>
    <div class="cards">{m.cards.map fun c =>
      <article key={toString c.id}>
        <Card state={c} onMsg={.card}/>
        <button onClick={.dismiss c.id}>✕</button>
      </article>}</div>
  </section>

invariant feedSafe   : cardSafe   for_each cards preserved_by update  -- ∀ card, stays valid
invariant feedStyled : cardStyled for_each cards holds_in view        -- ∀ card, renders styled

A tap inside a card routes back to that card by its key, so sorting or filtering the list can't misdeliver it. And the two for_each lines lift the card's contract to the whole feed: every card stays valid and styled, across every transition, proved automatically. An arm that can't preserve the contract fails the build by name. Examples/Feed.lean and Examples/Local.lean are the worked examples, including components that emit typed output up to their parent.

The feed's root is the architecture written out by hand: a Model, a Msg, a reducer, exactly what component generates behind the counter at the top. An app starts as one component (Qed.run Hello.app is the whole program, Examples/Hello.lean) and graduates to an explicit root when it needs routing, effects, or state of its own, without rewriting the components it already has.

Server-side rendering

You don't write any SSR code. A routed app already declares everything a server needs, its pages (router) and their data (queries), so qed build emits dist/ssr.mjs: a request handler built from your app. Per request it routes the URL, runs your queries server-side, renders with the same verified view the browser runs, and embeds the model in the page, so the client adopts the HTML with no refetch and no flash.

qed dev       # develop against the real thing: SSR + live reload
qed build     # dist/: the client bundle, plus ssr.mjs (the request handler)
qed start     # serve it: every route server-rendered, hydrated on load

Deploying stays simple. A static host still works (the same dist/ is a complete single-page app), and server rendering is one import away on any node or edge runtime:

import render from './dist/ssr.mjs';   // (request) => Response, that's the whole API

Examples/Bookshelf.lean is the worked app, a routed catalog with a detail page and an add-book form; its hydration test asserts a server-rendered load reaches interactive with zero API calls from the client.

Every feature above has an example like it in Examples/ and test/; Examples/README.md orders them as a tour, one concept at a time, from the hello-world component to the full Bookshelf app.

Does proving things cost you speed?

No. The engine already knows which subtrees are value updates, so a changed row's text and attributes go straight to its node, no diff. On the standard keyed-list benchmark it comes out about even with React on update, swap, and reorder. The transpiler turns Lean's tail recursion into real loops, so building, diffing, and walking a list all run in constant stack. Even 100,000 rows reconcile without trouble.

Getting started

The installer grabs elan (Lean's toolchain manager) if you don't have it, drops the framework into ~/.qed, and puts qed on your PATH. There's no heavy toolchain to download. qed build emits plain JavaScript. Verification isn't a separate step you have to remember. It runs inside every build:

qed dev        # watch sources, rebuild, serve (SSR) with live-reload  → localhost:8000
qed build      # production build → dist/ (client bundle + ssr.mjs)
qed start      # serve the build, server-rendered   (alias: preview)
qed test       # browser test suite (if present; needs node)
qed check      # verify only: proofs + no-sorry + axiom-clean, no artifacts
qed clean      # remove build outputs
qed new APP    # scaffold a new app
qed doctor     # report which dependencies are present

A failed proof is a failed build. The sources are grepped for sorry/admit/native_decide, and the axiom manifest runs, so a "proof" that smuggles in an axiom is caught. npm run dev / build / test work too. When you're hacking on the framework itself, the in-repo ./qed shim runs the CLI against this checkout.

Give it a try and state an invariant. Issues welcome at github.com/JacobAsmuth/qed/issues.

Where things live

Path What
Qed/Html.lean The typed virtual DOM every bit of syntax becomes.
Qed/Jsx.lean The JSX view syntax: <div class="x" onClick={.tap}>…</div>, expanded to el "tag" [attrs] [kids].
Qed/Notation.lean The attribute and event helpers (cls, onClick, value, …) JSX attributes expand to.
Qed/View.lean The rendering model: View (dyn/showIf/ifElse/forEach/dynNode) and the view% lift behind ui; built once, then changed bindings patch (patch_render/applyValues_render).
Qed/Runtime.lean The Elm Architecture: App, the ui builder, the Cmd effects + port/onPort, local components, and the render primitives.
Qed/Ssr.lean The per-request SSR step dist/ssr.mjs loops: route, run the app's queries, render, dehydrate.
Qed/Steps.lean The steps builder for effectful transitions: arms are bare models or (model, cmd) pairs.
Qed/Diff.lean The reconciler the engine uses internally: one children reconcile shared by positional and keyed, lazy memoization, and the diff_apply proof.
Qed/Json.lean JSON parser/renderer + the ToJson/FromJson classes, with the parse_depth_le/parse_render proofs.
Qed/Router.lean The Router class (round-trip law as a field), the router command, toURL/fromURL.
Qed/Schema.lean Field p, the Codec controls, and the schema command. One declaration yields the form (Draft + parse + formView + canSubmit_iff) and the JSON codec (ToJson/FromJson + decode/encode).
Qed/Component.lean Component, the for_each lift lemmas, and the component declaration (state/key/emits/view/set).
Qed/Invariant.lean The invariant command (preserved_by / holds_in / for_each). See docs/invariants.md.
Qed/Dom.lean / Qed/Driver.lean The DOM primitives (the one trusted boundary) and the impure driver.
Js/Backend.lean The Lean IR to JavaScript transpiler.
Examples/ · test/ Example apps and the browser tests that drive them.

License

MIT. See LICENSE.