Lecture 22 — 2018-04-05

Types for the lambda calculus

This lecture is written in literate Haskell; you can download the raw source.

import qualified Data.Map as Map
import Data.Map (Map)

We discussed type systems, first for a version of the While language with variables that could be either bools or ints, and then for the lambda calculus.

Broadly, we used type systems to identify programs that will “go wrong” when we run them. For example, in the While language we wanted to avoid trying to add a number to a boolean or to condition a while loop on a number. In the lambda calculus, we wanted to avoid applying a non-function or conditioning on a non-boolean.

We can to express our type systems in two ways: first, as a relation defined by inference rules; second, as a function that tried to assign types to terms.

Typing relation for the lambda calculus with booleans

First, let’s extend the lambda calculus to include booleans. Note that we’re forcing programmers to write the argument type on lambdas—it’s a convenience for our own design.

t ::= bool | t1 -> t2
e ::= x | e1 e2 | lambda x:t. e | true | false | if e1 e2 e3

Or, in Haskell:

data Type = TBool | TFun Type Type deriving (Eq, Show)

type VarName = String
data Expr =
    Var VarName
  | App Expr Expr
  | Lam VarName Type Expr
  | Bool Bool
  | If Expr Expr Expr deriving (Eq, Show)

We can define a step semantics for this calculus as follows:

lambda x:t. e is a value
true is a value
false is a value

v2 is a value
(lambda x:t. e1) v2 --> e1[v2/x]

e1 --> e1'
e1 e2 --> e1' e2

v1 is a value    e2 --> e2'
v1 e2 --> v1 e2'

if true e2 e3 --> e2

if false e2 e3 --> e3

e1 --> e1'
if e1 e2 e3 --> if e1' e2 e3

Note that many terms are stuck, i.e., unable to take a step: true doesn’t take any steps, nor do lambdas; other terms like true (lambda x:bool. x) or if (lambda y:bool. y) false true are also stuck. Having values be stuck is just dandy—values are done evaluating, so there shouldn’t be any steps to take. We have some non-values that are stuck, though, and that’s what we’ll use to define “wrongness”.

Let’s define a type system that rules out wrongness. First, we need a way to keep track of the types of variables; we’ll use a context Γ, which is the Greek letter “capital gamma”. We’ll write Γ |- e : t to say that the term e has type t under context Γ. Note that |- (pronounced “turnstile” or “shows”) and : (pronounced “colon” or “has type”) are just arbitrary syntax.

Γ ::= empty | Γ;x:t

lookup(Γ;x:t, x) = t
lookup(Γ;y:t, x) = lookup(Γ, x)

lookup(Γ,x) = t
Γ |- x : t

Γ |- e1 : t1 -> t2    Γ |- e2 : t1
Γ |- e1 e2 : t2

Γ;x:t1 |- e : t2
Γ |- lambda x:t1. e : t1 -> t2

Γ |- true : bool

Γ |- false : bool

Γ |- e1 : bool    Γ |- e2 : t    Γ |- e3 : t
Γ |- if e1 e2 e3 : t

Notice how we constrain both branches of an if to have the same type; how the rule for lambda extends the context to keep track of the argument type; and how the application rule makes sure we (a) only apply functions and (b) give suitable arguments to those functions.

Try to construct a derivation of the fact that Γ |- lambda x:bool. x : bool -> bool and Γ |- if true false true : bool. Convince yourself that true (lambda x:bool. x) or if (lambda y:bool. y) false true—our non-value stuck terms above—can’t be well typed.

Typing functions

The well typing relation Γ |- e : t is hopefully a clear way to understand, declaratively, which terms are well typed and which aren’t. But Haskell doesn’t have “relations”—it has functions. Let’s write a function that determines whether or not a given program is well typed.

The first question we must ask is: what type should the function have? In the relation Γ |- e : t, we can consider Γ and e as inputs and t as an output: you give me a context and a term, I’ll give you a type. So as a first cut:

type Context = Map VarName Type

typeOf :: Context -> Expr -> Type

But what should we do when a term isn’t well typed? We could throw an error, but that’s pretty annoying and makes it hard to use the typeOf function in other code. Instead, it’s better to make errors explicit:

type Context = Map VarName Type

data TypeError = ExpectedFunction Expr Type
               | Mismatch Expr Type Type {- expression, got, expected -}
               | UnboundVariable VarName deriving Show

typeOf :: Context -> Expr -> Either TypeError Type
typeOf g (Var x) =
  case Map.lookup x g of
    Nothing -> Left $ UnboundVariable x
    Just t -> pure t
typeOf g (Lam x t1 e) = do
  t2 <- typeOf (Map.insert x t1 g) e
  pure $ TFun t1 t2
typeOf g e@(App e1 e2) = do
  t1 <- typeOf g e1
  t2 <- typeOf g e2
  case t1 of
    TFun t11 t12 | t11 == t2 -> pure t12
    TFun t11 t12 -> Left $ Mismatch e t2 t11
    _ -> Left $ ExpectedFunction e1 t1
typeOf _ (Bool _) = pure TBool
typeOf g (If e1 e2 e3) = do
  t1 <- typeOf g e1
  t2 <- typeOf g e2
  t3 <- typeOf g e3
  case t1 of
    TBool | t2 == t3 -> pure t2
    TBool -> Left $ Mismatch e3 {- arbitrary! -} t3 t2
    _ -> Left $ Mismatch e1 t1 TBool

Note how we use do notation to automatically thread errors in the Either monad. Note also how each case of our recursive definition corresponds exactly to one of the rules in our relation. We have such a neat coincidence because our typing relation is syntax directed; that is, each syntax node can be typed by exactly one rule.