Computable Secrets / companion article

Programs are Proofs: the Curry-Howard Correspondence

Still from the video Programs are Proofs: the Curry-Howard CorrespondenceWatch ad-free in the member player

or watch free on YouTube ↗

In the 1930s, the logician Haskell Curry noticed something odd: the rules for combining functions looked exactly like the rules for combining logical propositions. Three decades later, William Howard wrote it down precisely (in a 1969 manuscript, published only in 1980). Programs and proofs are the same mathematical objects viewed from different angles. This is the Curry-Howard correspondence. We are going to build it from scratch.

The lambda calculus#

We need a precise language for talking about functions. The lambda calculus is a minimal notation for defining and applying them, nothing else, invented by Alonzo Church in the 1930s, around the same time Turing was building his machines.

There are exactly three things in it. Variables: x, y, z, names that stand for values. Abstraction: λx.e means "the function that takes x and returns e"; the lambda announces the input and the dot separates it from the body. Application: fa means "apply f to a."

That is the whole language. λx.x is the identity function: apply it to some value a, substitute a for x in the body, and get a back. λx.λy.x takes two arguments, first x then y, and returns the first. Apply it to a and you get λy.a, a function that ignores its argument; apply that to b and you get a. The second argument is discarded.

Types#

So far we have said nothing about what kinds of values these functions expect. The simply typed lambda calculus fixes this by assigning a type to every expression. We write λx.x:αα, and read the arrow as "to": give me an α, I will give you an α. The function λx.λy.x has type αβα: give me an α, then a β, and I will give you back that α. The β is ignored, but the type still records that it was accepted as input.

Now look at the arrow. As a type, αβ says "give me an α, I will produce a β." In logic, αβ says "from α, you can conclude β." Same symbol, same meaning. Let's make that precise.

Typing rules#

How do we know λx.x has type αα? We derive it from rules, each saying: if you know certain things about the parts, you can conclude something about the whole. Here Γ is the context, your list of assumptions.

The variable rule: if x:α is among your assumptions, then Γx:α. You just look it up.

The abstraction rule: assume the input type, derive the output type, and the lambda packages them together.

Γ,x:αe:βΓλx.e:αβ

The application rule: the function expects an α, you gave it an α, you get the promised β.

Γf:αβΓa:αΓfa:β

To derive λx.x:αα, the abstraction rule asks us to show x:α assuming x:α, and that is the variable rule. Two rules, one derivation.

The same rules, in logic#

In natural deduction, implication has the same two rules. Implication introduction says: if, after assuming α, you can derive β, then you have proven αβ.

Γ,αβΓαβ

Look at that rule, then look at the abstraction rule. The structure is identical. The typing context Γ is the set of assumptions, the type α is the proposition α, and the term λx.e is the proof. The logic rule does not bother naming its proof; it just says a derivation exists. The typing rule tells you what the derivation actually is: the lambda term.

Implication elimination is modus ponens: from αβ and α, conclude β. That is the application rule with the terms erased. Modus ponens is function application.

Pairs are "and", sums are "or"#

Functions are not the only type. If a:α and b:β, the pair (a,b) has type α×β; you need both components to construct it, and you use it by projecting out a component with first or second. In logic this is conjunction: to prove αβ you need a proof of each, and from a proof of αβ you may conclude α, or conclude β. Pairing is conjunction introduction, and the two projections are the two eliminations.

Sums mirror disjunction. With a value of type α, you can inject it into α+β by tagging it left: "I have an α, and that is enough for an α-or-β." A β tags right. To use a sum, you case-split: if you can handle the α case and the β case, you are covered. In logic, from α conclude αβ, and to use αβ you argue by cases. Proof by cases is pattern matching.

Currying is a theorem#

Let's trace the full correspondence on a specific example. If you have done any functional programming, you have probably curried a function: turned a function expecting a pair of arguments into one that takes them one at a time. The program is

λf.λx.λy.f(x,y)

with type ((α×β)γ)αβγ. Take the bundled function f and return a function that accepts x, then y, then feeds them to f as a pair.

Read the type as a proposition: if α and β together imply γ, then α implies that β implies γ. You can always unbundle a joint hypothesis into two sequential ones.

Build the typing derivation. Assume f:(α×β)γ, x:α, and y:β. By the pair rule, (x,y) has type α×β. By the application rule, f(x,y) has type γ. Then discharge the assumptions one at a time with three lambdas: first y, then x, then f.

Now read the same tree as a proof. Assume αβ implies γ. Assume α. Assume β. From α and β, conclude αβ: conjunction introduction. Apply modus ponens with the first assumption to get γ. Discharge β, then α, then the original hypothesis. The theorem is proved.

Currying is a theorem in propositional logic. The program is the proof.

Reading programs as theorems#

Once you see this, you can read any typed program as a theorem. The identity function λx.x:αα proves that every proposition implies itself. The constant function λx.λy.x:αβα proves that knowing α, no additional hypothesis can take it away.

Pause and try: what theorem does function composition prove?

Composition takes f from α to β and g from β to γ and returns λx.g(f(x)). Its full type is

(αβ)(βγ)(αγ).

Read it as a proposition: if α implies β, and β implies γ, then α implies γ. Transitivity of implication. The proof is function composition itself: given an α, run it through f to get a β, then through g to get a γ. Two uses of the application rule under three lambdas.

The correspondence, and where it leads#

Types are propositions. Programs are proofs. A function from α to β is a proof that α implies β, a pair is a proof of "and", a tagged union is a proof of "or", and type checking is proof verification. Your compiler has been verifying theorems every time you build your code.

One precision matters: the logic on the other side is intuitionistic propositional logic. Every proof is a construction, because every proof is a program. Classical principles like the excluded middle and Peirce's law have no lambda term at all; recovering them takes control operators, as Timothy Griffin showed in 1990.

And this is only the beginning. The simply typed lambda calculus corresponds to propositional logic. Add polymorphism and you get second-order propositional logic. Add dependent types, types that can refer to values, and you get predicate logic. Languages like Coq, Agda, and Lean are built on this; in those languages, writing a program and proving a theorem are the same activity.

Every well-typed program you have ever written was a theorem, with one caveat: in a language with general recursion, a looping program inhabits every type, so the logic is inconsistent. The correspondence carries its full weight in total languages, which is why proof assistants demand that every program terminate.

If you remember three things#

  1. The lambda calculus has three constructs, and the simply typed version assigns each term a type by three rules: variable, abstraction, and application.
  2. Those rules are natural deduction with the proofs written in: abstraction is implication introduction, application is modus ponens, pairs are conjunction, sums are disjunction.
  3. A well-typed program is a proof of its type read as a proposition; currying proves that a joint hypothesis can be split, and dependent types extend the idea all the way to Coq, Agda, and Lean.

Discussion

No comments yet. Be the first.