We begin our elaboration story by looking at how we can elaborate concrete expressions into intrinsically-typed ones. We will do so be examining the evaluation of a small expression language into the intrinsically-typed version we created in lecture when introducing De Bruijn indices.

Concrete & Abstract Syntax Trees

We begin by establishing the concrete and abstract syntax trees we will be using.

Here are the types our language will support.
data Ty = BOOL | INT
We have two types to explain decidably equality of 'types'.
Here is the concrete term syntax we will be using.
namespace Concrete
  public export
  data Expr : (state : Type) -> Type where
    B : (state : a) -> (b : Bool) -> Expr a

    And : (state : a)
       -> (lop : Expr a)
       -> (rop : Expr a)
              -> Expr a

    Var : (state : a)
       -> (name  : String)
                -> Expr a

    Let : (state : a)
       -> (name  : String)
       -> (type  : (a, Ty))
       -> (expr  : Expr a)
       -> (scope : Expr a)
                -> Expr a
Here is the intrinsically-typed syntax we will be elaborating to.
data Expr : (ctxt : SnocList Ty)
         -> (type : Ty)
                 -> Type
  where
    B : (b : Bool) -> Expr ctxt BOOL
    And : (l : Expr ctxt BOOL)
       -> (r : Expr ctxt BOOL)
            -> Expr ctxt BOOL

    Var : (idx : Var type ctxt)
              -> Expr ctxt type

    Let : (this : Expr  ctxt    typeA)
       -> (body : Expr (ctxt :< typeA) typeB)
               -> Expr  ctxt           typeB

A Type Checking Algorithm for Expressions

With our abstract syntax Expr and Exists we can describe how to check that an expression is well-typed. Specifically, we will describe a checking algorithm where the assumed type of the expression is given. Within our checking algorithm, we want to state the relation between a type and an expression under a given context.

The type constructor for this algorithm will take the same form (almost) of our nameless construction of terms. There will be a typing context and a type, but we will also need the concrete syntax we are typing.

data Expr : (ctxt : SnocList (String, Ty))
         -> (type : Ty)
         -> (expr : Expr a)
                 -> Type
  where

While Expr representing abstract syntax is intrinsically-typed, our checking algorithm Expr is an extrinsic checking of terms. We need this setup as we are going from an untyped to typed world.

We are also presenting types first, their terms second. This is for valid reasons: Our types say \ni

With Expr we can start to describe how we type expressions.

B
is indicative of typing expressions. Here we assert that the type of booleans must be of type bool.
    B : (b : Bool)
          -> Expr ctxt BOOL (B st b)
And
is indicative of typing binary (even unary and n-ary) operations. We assert what the types of the operands and operations are. For booleans, they must be of type BOOL.
    And : (lop : Expr ctxt BOOL         lAST)
       -> (rop : Expr ctxt BOOL              rAST)
              -> Expr ctxt BOOL (And st lAST rAST)
Variables
like variables when creating intrinsically-typed expressions, we need to check that a term exists within the typing context. Here we use Exists to look up the type pointed to by the key from the concrete syntax.
    Var : Exists key type ctxt
       -> Expr ctxt type (Var st key)
Let
when elaborating under (let-)binders we need to first elaborate the term being bound, checking that it has the correct type as dictated by the annotation. We then check the scope and extend the typing context accordingly.
    Let : (this  : Expr  ctxt           typeA expr)
       -> (scope : Expr (ctxt :< (name, typeA)) typeB sAST)
                -> Expr  ctxt typeB (Let st name (st', typeA) expr sAST)

We will not look at type inference or bi-directional type checking. The latter of which is very interesting and some thing that you should not look at ;-)

Like Exists will also need to demonstrate uniqueness of typing. That is, two expressions in the same typing context will have the same type.

unique : Expr ctxt typeA expr
      -> Expr ctxt typeB expr
      -> Equal typeA typeB

Proof is induction over terms to proof Refl.

Complete the implementation of unique.

When looking at intrinsic typing of expressions in lecture 1, you were asked to write both ill-and-well-typed terms. Try writing them again but using this version of Expr.

Which terms can you reconstruct and which ones can you not?

Extracing Intrinsically-Typed Terms from Propositions

With our elaboration proposition, we can look at extracting intrinsically-typed terms. We can do so by recursing over the structure of Elab to produce an intrinsically-typed term Expr. We need to ensure that we also go from a named to nameless context. Here, we have chosen to use a little type-level computation by applying snd to each element in the context.

toTerm : Expr                  ctxt  type expr
      -> Expr (map Builtin.snd ctxt) type

Complete the definition of toTerm.

Intermezzo: Decidablity of Types

When elaboration expressions, statements, and methods, we will need to assert if two types (for data and for methods) are equal or not.

We will not look at that here, but note that equality normally requires one to compare constructors pair wise. Within the project stub for Olaf, we have implemented decidable equality on our types using a little technique taught to me by gallais in which we compare constructor heads.

More information can be found in Olaf.Types.

For now we will assume that we have an instance of decidable equality for Ty:

DecEq Ty where

Complete the DecEq instance for Ty:

DecEq Ty where
  decEq pat = ?decEq_ty_rhs

Stack-able Error Messages

We have almost all the equipment necessary to type check expressions. The last bit we need are error messages.

In the previous section we introduced informative Dec. Before we describe elaboration, we need to have suitable error messages.

For this we will use the following datatype Error, which enables us to stack errors together with file contexts to trace where errors originate from. We will also have generic error messages for when we need them. Importantly, NotBound and MismatchTy will help us report when variables are not bound or we encounter a type mismatch.

data Error a = Stack a Error
             | E String
             | NotBound      String
             | MismatchTy   Ty Ty

We have deliberately:

  1. made the stacking of things agnostic, although we will treat them as FileContexts it is nonetheless a bold assumption on our part.

  2. not defined a Show instance here as we want that to be customised by the end-programmer. For example, if you want a complete stack trace or the last file context to appear…

We can use Error and informative Dec to create a more informative decision for type equality where we return an instance of Error capturing the type mismatch when two types are not equal.

typeEq : (x,y : Ty) -> Dec (Error a) (x = y)
typeEq x y with (Equality.decEq x y)
  typeEq x x | (Yes Refl)
    = Yes Refl
  typeEq x y | (No contra)
    = No (MismatchTy x y) contra

Proving Elaboration is complete.

We can now move on to proving that elaboration is sound and complete. First. we define our checking function. We have called it check because we are type checking our AST. Checking requires that we have a named typing context, the type we think the expression has, and the expression itself.

The result of checking will either be a runtime accessible Error message, paired with a proof of void, or an intrinsically-typed (checked) expression.

check : (ctxt : SnocList (String, Ty))
     -> (type : Ty)
     -> (expr : Expr a)
             -> Dec (Error a) (Expr ctxt type expr)

Contants

We begin by type-checking Boolean values, first ensuring that we are indeed checking that our input type is a boolean. If the type is not BOOL we can immediately fail with Refl as we can only type boolean constants using BOOL. It is here that we can use the state information from the AST to populate the error message.

If the type is BOOL we can immediately return an intrinsically-typed boolean value.

check ctxt type (B state b) with (typeEq type BOOL)
  check ctxt BOOL (B state b) | (Yes Refl)
    = Yes (B b)

  check ctxt type (B state b) | (No msg no)
    = No (Stack state msg)
         (\case (B b) => no Refl)

Binary Operations

Typing binary operations follows the same pattern as constants:

  1. First we check if our input type is what we think it should be;
  2. Second we can then look at each operand in turn checking that they have their assumed types.

As with any failure we must also:

  1. append to the error stack;
  2. prove Void using anything we have learned when failing.
check ctxt type (And state lop rop) with (typeEq type BOOL)
  check ctxt BOOL (And state lop rop) | (Yes Refl) with (check ctxt BOOL lop)
    check ctxt BOOL (And state lop rop) | (Yes Refl) | (Yes ltm) with (check ctxt BOOL rop)
      check ctxt BOOL (And state lop rop) | (Yes Refl) | (Yes ltm) | (Yes rtm)
        = Yes (And ltm rtm)
      check ctxt BOOL (And state lop rop) | (Yes Refl) | (Yes ltm) | (No msg no)
        = No (Stack state msg)
             (\case (And _ r) => no r)

    check ctxt BOOL (And state lop rop) | (Yes Refl) | (No msg no)
      = No (Stack state msg)
           (\case (And l _) => no l)

  check ctxt type (And state lop rop) | (No msg no)
    = No (Stack state msg)
         (\case (And _ _) => no Refl)

Variables

Typing variables requires that we use isBound to check that the name is bound within in the context, and ensure that the expected type is the same as the given type.

Reporting failure for typing variables almost follows the pattern we have seen for both binary operations and constants. A key difference is when producing the proof of void. We need to use our knowledge that indices are unique to help push the proof through.

check ctxt type (Var state name) with (isBound name ctxt)
  check ctxt type (Var state name) | (Yes (typeB ** idx)) with (typeEq type typeB)
    check ctxt type (Var state name) | (Yes (type ** idx)) | (Yes Refl)
      = Yes (Var idx)
    check ctxt type (Var state name) | (Yes (typeB ** idx)) | (No msg no)
      = No (Stack state msg)
           (\case (Var x) => case unique x idx of
                                  Refl => no Refl)

  check ctxt type (Var state name) | (No contra)
    = No (Stack state (NotBound name))
         (\case (Var x) => contra (type ** x))

Let-bindings

Finally we look at checking let-bindings.

We first check that our bound expression has the type indicated within binding, reporting a failure if this is not true. Second, when checking the scope, we do so by extending the typing context with the name-type pairing.

check ctxt type (Let state name (st',ty) expr scope) with (check ctxt ty expr)
  check ctxt type (Let state name (st',ty) expr scope) | (Yes etm) with (check (ctxt :< (name,ty)) type scope)
    check ctxt type (Let state name (st',ty) expr scope) | (Yes etm) | (Yes stm)
      = Yes (Let etm stm)

    check ctxt type (Let state name (st',ty) expr scope) | (Yes etm) | (No msg no)
      = No (Stack state $ msg)
           (\case (Let _ x) => no x)

  check ctxt type (Let state name (st',ty) expr scope) | (No msg no)
    = No (Stack state msg)
         (\case (Let this _) => no this)

Evaluate the following terms:

Recall the ill-typed terms from earlier. Represent these terms in Expr () and check if those terms fail to check.

Extend the definition of Expr to include support for integers and their addition, and extend eval as required.

For Expr to represent Ola expressions, you will have to remove Let.