The Interplay Between Metaprogramming and Computation in Lean

Tags: , ,
June 28, 2025

This week and next I am at the Oregon Programming Languages Summer School (OPLSS). With well over a hundred participants across academia and industry I have had ample opportunities to explain my research, including my choice to primarily use Lean for formalization. One of the many reasons is its metaprogramming system, which to my knowledge is unique among proof assistants. I don’t claim to be a fully fledged expert in Lean’s internals, but an example in one of Nada Amin’s excellent lectures prompted me to write this short blog post covering some basics.

In some sense, every piece of Lean code is a metaprogram, as Lean 4 is self-hosted. Every Lean program takes an internally defined inductive type of Lean syntax and transforms it into another inductive type representing a Lean expressionSee Lean.TSyntax, Lean.Syntax, and Lean.Expr

. This process is called elaboration. This is not limited to just constructs of the language, but is extensible by the end-user who is provided the ability to create fully custom syntaxes and elaborations, written as plain Lean code within a hierarchy of monadsThe Metaprogramming in Lean 4 book is a great introductory resource, but unfortunately much of this part of the language is very sparsely documented. There is however a dedicated channel for metaprogramming on the Lean Zulip where very experienced Lean users are often available and willing to help!

.

We encountered the following in yesterday’s lectureHere is a Lean 4 Web link which includes some of my own additions.

,

-- Object level: inductively defined Even predicate
inductive Even : Nat  Prop where
  | zero : Even 0
  | succ_succ :  n, Even n  Even (n + 2)

-- Meta level: computational even checker
def isEven : Nat  Bool
  | 0 => true
  | 1 => false
  | n + 2 => isEven n

theorem isEven_iff {n : Nat} : isEven n = true  Even n := by
  constructor
  · fun_induction isEven n <;> simp +contextual [Even.zero, Even.succ_succ, *]
  · intro h; induction h <;> simp [isEven, *]

instance (n : Nat) : Decidable (Even n) :=
  decidable_of_decidable_of_iff isEven_iff

example : Even 2 := by decide
example : Even 4 := by decide
example : Even 6 := by decide
example : Even 100 := by decide
example : Even 1000 := by decide +kernel -- skip the elaborator when evaluating `isEven`.
example : ¬ Even 101 := by decide
example :  n < 10, Even (2 * n) := by decide
example :  n < 10, Even (2 * n)  ¬ Even (2 * n + 1) := by decide

an example provided by the eternally helpful Kyle Miller. To me, this code is astonishingly dense in its interplay between metaprogramming and computation, especially with some of the details being hidden by the elaboration and typeclass systems.

Let’s start with understanding the isEven function. Intuitively, it represents a decision procedure that identifies the even numbers. At first glance, it may seem a bit odd that we are able to use this function in the proceeding proof to perform induction. Looking at the documentation for fun_induction clears things up a bit. When we define isEven, we also get an automatically generated induction principle that intuitively covers the natural numbers with the two chains \((0 → 2 → 4 → \dots)\) and \((1 → 3 → 5 → \dots)\). With some renaming for readability, this generated definition is:

def isEven.induct 
  (P : Nat  Prop) (P0 : P 0) (P1 : P 1) (ind :  n, P n  P (n+2)) (n : Nat) : P n := 
  match n with
  | 0 => P0
  | 1 => P1
  | n'+2 => ind n' (isEven.induct P P0 P1 ind n')

and in fact we could have essentially the same proof working directly with this generated principle:

theorem isEven_iff' {n : Nat} : isEven n = true  Even n := by
  constructor
  · intros h
    induction n using isEven.induct <;> simp [isEven] at * <;> constructor
    next ih => exact ih h
  · intro h; induction h <;> simp [isEven, *]

While I don’t claim that this sort of induction is unique to Lean, I will emphasize again this code generation is all done via Lean’s metaprogramming system. In general, Lean can derive induction principles for functions that exhibit structural, well-founded, and mutual recursion. This is a highly nontrivial development, comprising just over 1700 of Lean code. For some introductory reading, I suggest Joachim Breitner’s blog post on functional induction.

The final piece of this puzzle is understanding the examples at the end of the code snippet. First, we should know that in the background we have the Decidable typeclass, which identifies propositions for which we have a procedure that returns true or false. As booleans have decidable equality (essentially by definition), we are able to use decidable_of_decidable_of_iff to transfer the decidability of isEven to our proposition Even.

Now we are prepared to make use of the decide tactic. The intuition here is that given an instance of decidability, we should be able to traverse the corresponding decision procedure, along the way constructing our proof term. By default, this happens during elaboration, where the Lean compiler sees the syntax of the decide tactic, then assembles the intended Lean expression.

Interestingly, this is also an extensible operation. You’ll notice that one of the examples with a larger number uses the configuration decide +kernel. This bypasses elaboration and instead uses the Lean kernelFor more information on the Lean kernel, see Type Checking in Lean 4. There are external implementations in Rust and in RPython to typecheck kernel export files.

for reduction. This can help performance, but does not introduce any additional soundness issues, as the kernel is a compilation target that we already consider as part of our trusted codebase. There is also an option decide +native which uses the Lean compiler to evaluate. This is generally discouraged, as this does increase the trusted portion of the codebase beyond its usual bounds, and can introduce subtle bugs. As always, this is all written as a Lean metaprogram Lean.Elab.Tactic.evalDecideCore, where we can examine the different paths taken for the usual elaboration, kernel reduction, or compiler reduction.