Synthesised or Checked?

A classic example in the world of dependently typed programming is that of presenting the STLC (and extensions) as well-typed EDSLs.

The STLC as an Idris EDSL.

Below we give a simple example of the STLC with Booleans.

data Ty = TyBool | TyFunc Ty Ty

data STLC : (ctxt : List Ty)
         -> (type : Ty)
                 -> Type
  where
    Var : Elem type ctxt -> STLC ctxt type

    Func : (body : STLC (type_param :: ctxt)                    type_body)
                -> STLC                ctxt  (TyFunc type_param type_body)

    App : (func  : STLC ctxt (TyFunc type_param type_body))
       -> (param : STLC ctxt         type_param)
                -> STLC ctxt                    type_body

    B : (b : Bool) -> STLC ctxt TyBool

STLC is intrinsically well-typed in that, at the type-level, all terms are given a type type and has the local typing context. Within STLC, bound variables are represented using De Bruijn indices encoded as existential quantification over a type-level context.

The STLC Formally.

Let us compare this with the formal notation, taken from TAPL, which we present informally. First we define, names, types, and typing-contexts:

x         ::= names
s,t \in T ::= B | s -> t
g   \in G ::= {} | g + (x:t)

Then we define our expressions:

b \in B ::= true | false
e       ::= x | b | (fn x : t . e) | (e $ e)

and our typing rules, which we excluded the introduction rules for Booleans.

First, variables:

(x : t) \in g
[ Var ] -----
g |- x : t

then functions:

g + (x : s) |- e : t
[ Func ] -------------------
g |- (fn x : t . e) : s -> t

and lastly application:

g |- f : s -> t
g |- e : s
[ App ] --------
g |- (f $ e) : t

Within the formulation, function definitions are explicitly typed. It is the STLC after all!

An interesting thought, to me at least, is:

Is STLC as explicitly typed as the formal notation?

For example, compare:

((fn x : Bool . x) $ true)

with

example : STLC Nil TyBool
example = App (Func (Var Here)) (B True)

Types can be synthesised, or checked.

To beter understand this question, we have to borrow some terminology from Bidirectional type-checking where the types for terms are either:

  • Synthesised—types are constructed from the terms; or
  • Checked—types are checked against the given term.

We can even give a variant of the bi-directional typing rules, where we replace the standard type annotation : with checks and synths.

First, variables:

(x : t) \in g
[ Var ] -----
g |- x synths t

then functions:

g + (x : s) |- e checks t
[ Func ] -------------------
g |- (fn x : t . e) checks s -> t

and lastly application:

g |- f synths s -> t
g |- e checks s
[ App ] --------
g |- (f $ e) synths t

What this means is that for function definitions we have to calculate the type of the function body e and check it against the presented type. For function application is, get the type of the function from the function itself, check that the argument e has the correct type we generated from f, and we can then generate the final type t.

Morally, the STLC is not Intrinsically Typed.

So going back to our example example, we can reframe the question as:

Does our encoding of the STLC (STLC) synthesis types where instead they should be checked?

The simple question is yes it does, but it depends on context! Let us now take our example again and have a look at it:

example : STLC Nil Bool
example = App (Func (Var Here)) (B true)

Here it’s definition is using Idris’ type checker to check if the body of example matches it’s type as defined in the type-signature.

Let’s now look at the definition of App

App : (func  : STLC ctxt (TyFunc type_param type_body))
   -> (param : STLC ctxt         type_param)
            -> STLC ctxt                    type_body

and compare with the bi-directional version:

g |- f synths s -> t
g |- e checks s
[ App ] --------
g |- (f $ e) synths t

I contend that unless an expression from STLC is bound to an Idris name, it’s types will be synthesised rather than checked. This means that both the types for func and param (f and e in the formal notation) are synthesised, constructed from the terms.

Now, let’s look at Func

Func : (body : STLC (type_param :: ctxt)                    type_body)
            -> STLC                ctxt  (TyFunc type_param type_body)

and it’s bi-directional version:

g + (x : s) |- e checks t
[ Func ] -------------------
g |- (fn x : t . e) checks s -> t

Again, I contend that the types in Func are synthesised unless they are bound to an Idris name.

This, to me, means that morally STLC is not a true realisation of the STLC:

Types in function definitions are synthesised when they should be checked!

Which means the type you think the function has, might not be the type it actually has!

We can fix it with value level annotations.

Well the solution is rather simple, we can add an explicit type annotation to the definition of Func. For example:

data TypedSTLC : (ctxt : List Ty)
              -> (type : Ty)
                      -> Type
  where
    Var' : Elem type ctxt -> TypedSTLC ctxt type

    Func' : (type_param : Ty)
         -> (body : TypedSTLC (type_param :: ctxt)                    type_body)
                 -> TypedSTLC                ctxt  (TyFunc type_param type_body)

    App' : (func  : TypedSTLC ctxt (TyFunc type_param type_body))
        -> (param : TypedSTLC ctxt         type_param)
                 -> TypedSTLC ctxt                    type_body

    B' : (b : Bool) -> TypedSTLC ctxt TyBool

This gives Idris’ type-checker a source of truth when synthesising the type of Func' and checking the type when Func' is used.

Thus our running example is now:


example' : TypedSTLC Nil TyBool
example' = App' (Func' TyBool (Var' Here)) (B' True)

The End.

PS, this file is a literate Idris2 file ;-)