Simple and Inductive Types

Types Provide Confidence


Learning Objectives

  • You know what types are.
  • You know the challenges related to type checking lambda calculus.
  • You know of typing contexts.
  • You know of hypothetical typing.
  • You understand the typing rules of simply typed lambda calculus.

Introduction

In this chapter, we will study how a type system could be added on top of lambda calculus and develop the theory of the simply typed lambda calculus. To start off, let’s think about types in the language developed in the previous chapter — a lambda calculus style language with booleans and natural numbers.

As a reminder, the type quantifier binds a type variable and makes the type polymorphic — i.e. able to work with any type that is provided. We will use the double colon notation (::) to denote the type of the term in question.

true::Boolif true then false else true::Bool5::Nat5+5::Nat𝜆𝑛.5+𝑛::NatNatid=𝜆𝑥.𝑥::𝑇,𝑇𝑇

Each of these types except for the last is a simple type — the last type contains a quantifier, so it is not simple.

What’s common between these terms is that they are closed terms.

Terms like

𝜆𝑥.plus_one𝑥if true then happy else sad(𝜆𝑥.𝜆𝑦.𝑥)𝑦

are not closed, but we can (and have to) still reason about their types. For example, plus_one has to have the type of a function, and happy and sad have to have the same type.

Type Checking: A Motivation

Let’s motivate the topic of this chapter by considering the following question.

Why do we need a(static) type checker?

The answers to this are manifold, although quite subjective. Here are what the course authors find most important.

  1. Expressiveness of the language. A programming language without a static type checker lets the user write functions which can accept and return data of different (runtime) types without enforcing a contract. Such languages are error prone to crash when doing computations with an unexpected value. As an example, we can write the double function in Python as follows

    def double(x):
        return 2 * x

    The function can be called with values ranging in integers, floating points, booleans and even strings and lists. The programmer has to be aware of these details of the language to have confidence that their code works correctly. The code itself is not expressing its range of functionalities and instead hides the functionality behind the multiplication operator. As the multiplication operator can be defined to return an arbitrary value, there is no requirement that double(x) has the same runtime type/class as x.

    Compare this with Rust, where the translation of the double function can only be called with integers

    fn double(x: i32) -> i32 {
        2 * x
    }

    There is no hidden behavior for what happens when calling the function with booleans or lists. If we wanted to emulate the Python function’s behavior more closely, we would need to use traits (type classes) or sum types. The sum type, or enum version, of the code could look as follows

    enum IntOrString {
        Int(i32),
        String(String),
    }
    
    fn double(x: IntOrString) -> IntOrString {
        match x {
            IntOrString::Int(n) => IntOrString::Int(2 * n),
            IntOrString::String(s) => IntOrString::String(format!("{s}{s}")),
        }
    }

    But the type of the function double : IntOrString -> IntOrString doesn’t express a key invariant: the variant is preserved during the function. As this is quite an important feature of the function, how could we make the type reflect the behavior better?

    As an example of the expressiveness of Rust’s type system, we solve this “expression problem” with the use of a trait called Doubleable.

    trait Doubleable {
        fn double(self) -> Self;
    }
    
    impl Doubleable for i32 {
        fn double(self) -> Self {
            2 * self
        }
    }
    
    impl Doubleable for String {
        fn double(self) -> Self {
            format!("{s}{s}")
        }
    }
    
    // This is exactly `Doubleable::double`
    fn double<T: Doubleable>(t: T) -> T {
        t.double()
    }

    Now there is no question that double(x) has the same type as x, and the behavior can be extended to other types as necessary. Types are therefore a strong mechanism for enforcing simple contracts such as what kind of data a function accepts and returns. Polymorphism and type classes are a way to generalize a function to work with various concrete types: they express that a function can work with any type (possibly with some trait bounds).

  2. Confidence at runtime. Types eliminate a class of bugs where an incompatible runtime value is passed to a function. Such bugs can be hard to find if the range of values in normal/successful operation have been tested to work. Edge cases and error handling might be harder to test and what is worse, might involve runtime values outside the range that is encountered during normal/successful operation.

    With types, it’s usually easier to analyze what code does. For example, which function/method is called, depends on the object in question. But with static typing, we almost always know the concrete types involved, so it becomes easy to analyze which functions/methods are called.

  3. Compile-time guarantees and optimization.:

    With a strong static type system we are able to impose stronger guarantees about the code during compilation. Generally speaking this allows the compiler to generate more optimal code because it can make stronger assumptions. Let’s explore a few examples of this by exploring the behavior of the Rust compiler.

    The example below shows a seemingly uninteresting block of Rust code matching on a boolean value. What’s interesting is if we think what the code would look like if we couldn’t guarantee that b is a boolean, what if it was instead any value that fits into a byte? That would mean that our match case wouldn’t be exhaustive anymore.

    Languages that cannot guarantee type preservation, aka. dynamically typed programming languages, have to emit extra code in order to make sure that the runtime types match the operation.

    let b: bool = ...; // bool takes 8 bits of memory and is indistinguishable from u8 at runtime
    match b {
        true => ...,
        false => ...
    }

    Rust’s type system goes way beyond even most other static type systems. Some of the guarantees are particularly useful for optimization purposes, this example shows how the Rust compiler is able to generate better code for a seemingly simple function. The key in this optimization is that Rust’s type system, and more importantly, the borrow checker are able to guarantee that mutable references don’t have concurrent writers, which then allows the compiler to only read the value of b once. This is something the C-compiler cannot assume and therefore must always generate two reads to maintain correctness.

  4. APIs and compatibility. As the programming language ecosystem evolves, libraries undergo various changes to their API. Types are an excellent tool for expressing the API and its invariants. If a library changes in a breaking way, its clients will (be more likely to) get a type error rather than encounter a runtime bug. A type error is also much easier to debug than a runtime error — to retry after making adjustments, one only needs to compile the project.

Simple Types

In part 4 in Type Systems and Type Checking we added a type checker for conditional arithmetic (𝒞), and types were just an enumeration with two variants.

enum Type {
    Integer,
    Boolean,
}

𝒞 had both integers and booleans, but no functions. To describe the type of function, we need a more sophisticated type system.

Let’s begin by defining what types are in the simply typed lambda calculus (STLC) with booleans.

DefinitionType Formation Rules in STLCIn the simply typed lambda calculus, or STLC, with a boolean basetype, there are two constructors for types. Bool is a type, and thearrow type 𝑇𝑈 is a type given any types 𝑇,𝑈.We write “𝑇type” when 𝑇 is well-formed according to theformation rules.Booltype𝑇type𝑈type𝑇𝑈typeNotationParentheses can be added around types for visual separation.The notation for the arrow type, 𝑇𝑈, associates right, so 𝑇𝑈𝑉 can be parenthesized as 𝑇(𝑈𝑉), but not (𝑇𝑈)𝑉.The latter is an entirely different type (corresponding to a higher-order function).

Types in STLC (with any extensions) are simple, so they can’t contain polymorphic type quantifiers1.

ExampleThe following are simple types.BoolBoolBool(BoolBool)BoolBoolBoolBoolAs associates to the right, the last type is Bool(BoolBool).
Infinite Types Are Illegal

Types are defined inductively, so they must be finite. The infinite type-like object

BoolBoolBool

which comes for example from the recursive definition

𝑇=Bool𝑇

is not a type.

Loading Exercise...
DefinitionTerm (Simply Typed Lambda Calculus with Booleans)Terms in the simply typed lambda calculus with booleans areotherwise the same as in untyped lambda calculus with booleansexcept for abstractions.Instead of 𝜆𝑥.𝑡1 we must write a type 𝑇 after the variable name in alambda abstraction:𝜆𝑥:𝑇.𝑡1Exampletrueif true then false else true𝜆𝑏:Bool.𝑏𝜆𝑏:Bool.if b then false else b𝜆𝑓:BoolBool.𝑓true

As the dynamics of STLC are “the same” as in lambda calculus, we begin by studying its type checking.

Type Checking

The relation that a term 𝑡 has the type 𝑇 is denoted 𝑡::𝑇. Here are some STLC terms and their types.

Exampletrue::Boolif true then false else true::Bool𝜆𝑏:Bool.𝑏::BoolBool𝜆𝑏:Bool.if b then false else b::BoolBool𝜆𝑓:BoolBool.𝑓true::(BoolBool)Bool

How should we define this typing relation ::? As always, because terms have a recursive structure, a good first guess is inductively, i.e. recursively.

Let’s consider the following term.

𝜆𝑏:Bool.𝑏

As a lambda abstraction with parameter type Bool, we can immeditely say that its an arrow type with the domain Bool:

Bool?

However, to infer the type of the codomain, we need to type check 𝑏, a mere variable term. Clearly we need some way to keep track of variables in a context-like fashion.

Hypothetical Typing with Context

As a reminder, contexts were defined in Variables and Abstractions as follows.

DefinitionContextsA set of variable names is also called a context and usually denotedwith the Greek uppercase letter gamma Γ.

However, for type inference, the set of variable names is not enough, we also need the types of variables which are provided as part of the abstractions.

DefinitionTyping ContextA typing context is a list of pairs of variable names and types. Theletter Γ is used to also denote typing contexts.A typing context visually grows to the right, so for example 𝑥::Bool,𝑦::Bool is the two-element context with 𝑥 followed by 𝑦.The empty context is denoted with the symbol.ExampleThe following are typing contexts.Γ𝑥::BoolΓ,𝑥::Bool

Now, we can answer the question “what is the type of a variable” as follows.

DefinitionType of a VariableGiven a typing context Γ, the type of a variable 𝑥 is the type 𝑇given by the last pair 𝑥::𝑇 in the context Γ.If no such pair exists, the variable does not have a type, whichmeans it’s ill-typed.

The above definition requires that the type of a variable comes from the last occurrence of the variable in the context. This is important to notice in terms involving variable shadowing, i.e. nested abstractions with the same variable name.

Example𝑥has typeBoolin the context𝑥::Bool𝑥has typeBoolin the context𝑥::Bool,𝑦::𝑇𝑥has type𝑇in the context𝑥::𝑈,𝑥::𝑇𝑥has type𝑇in the contextΓ,𝑥::𝑇𝑥is ill-typedin the context

From now on, we refer to typing contexts using just the word “context” as it’s always “clear from the context” which kind of context we are working with.

Type Checking vs. Type Inference

Type checking is often used to refer to the verification process that takes in a term and a type and verifies the term really has the provided type.

On this course we use type checking to refer to the decision problem whether a term is well-typed or not. “Does this code type check?” is a decision problem as it has a yes/no answer.

Type inference is the term we use for the computational procedure for deriving the type from a term. If type inference fails the term is ill-typed. Type checking can then use type inference to check if the term is well-typed.

On this course, the terms are always from the simply typed lambda calculus, so type inference doesn’t need to work out the type of function parameters.

Loading Exercise...

Hypothetical Typing Rules

To define the typing relation ::, we define an auxiliary relation for hypothetical typing which keeps track of the context.

DefinitionHypothetical TypingIf Γ is a typing context, 𝑡 a term and 𝑇 a (simple) type, we denoteΓ𝑡::𝑇for the relation “𝑡 has type 𝑇 in the context Γ.”The relation is inductively defined using hypothetical typing rules orjust typing rules.

The base rules governing the typing of true and false terms are straight-forward.

As a general rule, Γ can be any (typing) context from now on.

Γtrue::BoolΓfalse::Bool

The typing rule for if-then-else requires that the condition is a boolean, and that the if-true and if-false subterms have the same type 𝑇.

Γ𝑡𝑐::BoolΓ𝑡1::𝑇Γ𝑡2::𝑇IteΓif𝑡𝑐then𝑡1else𝑡2::𝑇

Here’s an example instance of the Ite rule.

Example𝑐::Boolc::Bool𝑐::Boolfalse::Bool𝑐::Booltrue::BoolIte𝑐::Boolif𝑐thenfalseelsetrue::Bool

The second and third premise are instance of the base rule, but the first premise contains the variable 𝑐

𝑐::Boolc::Bool

This corresponds with the “type of a variable” defined earlier — we just need to express it as a typing rule. To express the “type of a variable” rule, we need to first express how to access a variable from the context. For this, we need to define the relation 𝑥::𝑇Γ.

As mentioned earlier, we need to search the context Γ in reverse, which is easy enough to express as follows

CtxVar𝑥::𝑇𝑥::𝑇𝑥::𝑇Γ𝑥𝑦CtxCons𝑥::𝑇Γ,𝑦::𝑈

The CtxVar rule says if Γ=𝑥::𝑇, then 𝑥::𝑇Γ. The CtxCons rule says that if 𝑥::𝑇Γ and 𝑥𝑦 (to require that we indeed have the last occurrence of 𝑥 in Γ) then we get 𝑥::𝑇Γ,𝑦::𝑈.

Now the “type of a variable” rule gives us Γ𝑥::𝑇 if 𝑥::𝑇Γ.

𝑥::𝑇ΓVarΓ𝑥::𝑇

Application Typing

The typing rule for an application term comes from our intuition of how function calls are typed. For 𝑡1𝑡2 to have type Bool, 𝑡1 should be a function from BoolBool and the argument 𝑡2 should have type Bool.

Γ𝑡1::𝑇1𝑇2Γ𝑡2::𝑇1AppΓ𝑡1𝑡2::𝑇2

If the type of 𝑡2 is not 𝑇1, the rule can’t be applied and typing will get stuck. Likewise if 𝑡1 doesn’t have an arrow type. As 𝑡1𝑡2 can’t have a type, we say it is ill-typed.

Abstraction Typing

To motivate the typing rule for a lambda abstraction, we observe that the application term (𝜆𝑥:Bool.𝑥)true would have the following typing derivation. As a reminder, a typing derivation is the tree structure consisting of instances of (hypothetical) typing rules.

Example?(𝜆𝑥:Bool.𝑥)::BoolBooltrue::BoolApp(𝜆𝑥:Bool.𝑥)true::Bool

To fill in the ?, we need to have a typing rule that matches the term 𝜆𝑥:Bool.𝑥. This would be the typing rule for abstractions.

To type check an abstraction, we need to check if the body of the abstraction is well-typed in the context where we have added the name of the binding variable and its type. In this case the context is empty, the binding variable with its type is 𝑥::Bool, so we recursively infer the type of the body 𝑥 in the context 𝑥::Bool. This branch is then conventiently closed by the Var rule.

With this typing rule, called Abs, we could fill in the example as follows:

ExampleVar𝑥::Bool𝑥::BoolAbs𝜆𝑥:Bool.𝑥::BoolBooltrue::BoolApp(𝜆𝑥:Bool.𝑥)true::Bool

And so we present the general form of the rule.

Γ,𝑥::𝑇1𝑡1::𝑇2AbsΓ𝜆𝑥:𝑇1.𝑡1::𝑇1𝑇2

You can verify that the instance used in the example matches the rule: Γ=,𝑇1=Bool,𝑇2=Bool,𝑥=𝑥,𝑡1=𝑥.

Using hypothetical typing, we can define the regular “non-hypothetical” typing relation “has type” as simply a special case with the empty context.

DefinitionHas Type (𝑡::𝑇)A term 𝑡 has type 𝑇 denoted 𝑡::𝑇 if and only if 𝑡 has type 𝑇 in theempty context, i.e.𝑡::𝑇We say that 𝑡 is well-typed, if there exists some type 𝑇 such that 𝑡::𝑇.

When a term doesn’t have a type in the empty context, we say that it’s ill-typed.

DefinitionIll-typedA term 𝑡 is ill-typed if 𝑡::𝑇 does not hold for any type 𝑇.ExampleThe following terms are ill-typed, i.e. don’t have a typetruefalse𝜆𝑥:Bool.𝑥true𝜆𝑥:BoolBool.𝑥(𝜆𝑥:Bool.𝑥)if true then(𝜆𝑥:Bool.𝑥)elsefalseif(𝜆𝑥:Bool.𝑥)thentrueelsefalse
Loading Exercise...

Type Erasure

As simply typed lambda calculus is more focused on type checking, its dynamics can be defined using the dynamics of untyped lambda calculus which we have already defined in the previous part. However, we only briefly visit this idea, and from the next chapter onwards, we define dynamics for STLC using call-by-value semantics.

Let’s denote the set of STLC terms (with booleans) with TypedTerm and use UntypedTerm to denote the set of terms of untyped lambda calculus (with booleans). We define a translation erase:TypedTermUntypedTerm which erases all types from a term. The translation works recursively, and for lambda abstractions, acts as follows

erase(𝜆𝑥:𝑇.𝑡1)=𝜆𝑥.erase(𝑡1)

To help distinguish between the different sets, we draw lines over the terms that are from the set TypedTerm, and underlines for terms from the set UntypedTerm. So given a 𝑡TypedTerm we get an erase(𝑡)UntypedTerm.

For other terms, erase simply reconstructs the term recursively.

erase(𝑥)=𝑥erase(𝑡1𝑡2)=erase(𝑡1)erase(𝑡2)erase(true)=trueerase(false)=falseerase(if𝑡𝑐then𝑡1else𝑡2)=iferase(𝑡𝑐)thenerase(𝑡1)elseerase(𝑡2)

Type erasure is therefore quite similar to desugaring as it’s also a translation between different languages.

To define the dynamics of STLC, we first erase types and then evaluate/multistep the untyped lambda term.

Loading Exercise...

Type Safety Revisited

(Syntactic) type safety has two main properties: preservation and progress. These are true theorems in the simply typed lambda calculus (with the boolean extension as well.)

TheoremProgressIf 𝑡 is well-typed, then 𝑡 is a value (true, false or an abstraction), orthere is some term 𝑡 such that 𝑡𝑡.TheoremPreservationIf 𝑡::𝑇 which can step 𝑡𝑡, then 𝑡::𝑇.

What it means to be a value is not defined here explicitly. If one defined it as “normal form”, progress would always be true, and therefore a useless property. A better notion of “value” is tied to “canonical forms” which are discussed later.

Suppose we are working with a term which is well-typed (has some type in the empty context). Let’s analyze what happens during its evaluation, if instead of erasing types we reduce the typed STLC terms instead.

If the well-typed term we are evaluating happens to be an application term 𝑡𝑓𝑡𝑣, both subterms 𝑡𝑓 and 𝑡𝑣 must be closed. Therefore, if 𝑡𝑓=𝜆𝑥:𝑇1.𝑡1, as 𝑡𝑣 is closed, the substitution [𝑥𝑡𝑣]𝑡1 must be legal.

Notice that we are only talking about a top-level application. The above logic doesn’t work with applications inside abstractions as the context there is not empty. By dropping the Abs reduction rule, we ensure that all substitutions that happen during evaluation have to be legal.

The definition for when a term is “Closed Under a Context Γ” was defined in part 5: Variables and Abstractions. The definition is straight-forward to extend to STLC terms.

DefinitionClosed Under a Context ΓLet 𝑡 be a term in STLC and Γ a set of variables (in a typingcontext). We define the relation 𝑡 is closed under Γ inductively asfollows:1.If 𝑡=𝑥 and 𝑥Γ then 𝑡 is closed under Γ.2.If 𝑡=𝑡1𝑡2 and both 𝑡1 and 𝑡2 closed under Γ, then 𝑡 is closedunder Γ.3.If 𝑡=𝜆𝑥:𝑇.𝑡1 and 𝑡1 is closed under Γ{𝑥}, then 𝑡 is closedunder Γ.As we also have booleans in our calculus, the relation is extended asfollows:1.If 𝑡=true or 𝑡=false, then 𝑡 is closed under Γ (or any context).2.If 𝑡=if𝑡𝑐then𝑡1else𝑡2 and all 𝑡𝑐,𝑡1and𝑡2 are closed under Γ,then 𝑡 is closed under Γ.

Next, we prove a key lemma that’s used to prove that substitution with well-typed terms is always legal. The proof demonstrates working with typing derivations and their inductive structure. It’s not strictly necessary to understand, but gives a picture of what proofs involving typing rules look like.

Feel free to skip the proof on a first reading.

LemmaTyped Terms are Closed Under the ContextIf Γ𝑡::𝑇, then 𝑡 is closed in the set of variables in Γ.Proof: Assume Γ𝑡::𝑇 is the conclusion of some typing derivation Δ. Sincethe typing rules are defined inductively, we can proceed with induction in thetyping derivation Δ.1.First, let’s consider the case where Δ is an instance of the Var rule.Δ=𝑥::𝑇ΓVarΓ𝑥::𝑇Because the derivation is valid, we can assume 𝑥::𝑇Γ. As theconclusion is still Γ𝑡::𝑇, 𝑡 must be some variable 𝑥. As we have 𝑥::𝑇Γ, 𝑥 is in the set of variables in Γ. Therefore 𝑡 is closed under (the set ofvariables in) Γ.2.Next, let’s consider the case where Δ is an instance of the App rule.Δ=Γ𝑡1::𝑇1𝑇2Γ𝑡2::𝑇1AppΓ𝑡1𝑡2::𝑇2We also have the induction hypotheses for the two subderivations:𝑡1 is closed under Γ.𝑡2 is closed under Γ.This time 𝑡=𝑡1𝑡2 and from the induction hypotheses we immediately getthat 𝑡 is closed under Γ.3.If Δ is an instance of the Abs rule.Δ=Γ,𝑥::𝑇1𝑡1::𝑇2AbsΓ𝜆𝑥:𝑇.𝑡1::𝑇1𝑇2The induction hypothesis for the subderivation gives us that 𝑡1 is closedunder the set of variables in Γ,𝑥::𝑇1. As this set (with abuse of notation:Γ{𝑥}) is the union of the set of variables in Γ and {𝑥} we have that 𝜆𝑥:𝑇.𝑡1 is closed under the context Γ.4.If Δ is an instance of True or False, then 𝑡 is true or false, and thereforeclosed under Γ.5.If Δ is an instance of Ite, thenΔ=Γ𝑡𝑐::BoolΓ𝑡1::𝑇Γ𝑡2::𝑇IteΓif𝑡𝑐then𝑡1else𝑡2::𝑇We also have the induction hypotheses for the subderivations: 𝑡𝑐,𝑡1,𝑡2 areclosed under Γ. As 𝑡=if𝑡𝑐then𝑡1else𝑡2, from the induction hypothesiswe get that 𝑡 is closed under Γ.

As a side-note about the above proof, as typing rules match the different kinds of terms one-to-one, an alternative proof could use induction in the term 𝑡.

From this, we immediately get the following special case for when Γ=.

CorollaryWell-Typed Terms are ClosedIf 𝑡 is well-typed, then 𝑡 is closed.

Using the corollary, we can prove that substitution with well-typed terms is always legal. We don’t explicitly define what it means for substitution to be legal for STLC terms, as it’s nearly identical to the untyped case (which you can find in part 5: Variables and Abstractions).

LemmaIf 𝑡 is well-typed, and 𝑡=𝑡1𝑡2, then 𝑡1 and 𝑡2 are well-typed.LemmaIf 𝑡𝑣 is closed, then the substitution [𝑥𝑡𝑣]𝑡1 is legal.Proof: As FV(𝑡𝑣)= we have ΓFV(𝑡𝑣)= for every context Γ. Thereforethe conditions for the substitutition to not be legal can not be met for any𝑥,𝑡1.TheoremWell-Typed Substitution is LegalIf 𝑡=(𝜆𝑥:𝑇.𝑡1)𝑡𝑣 is well-typed, then the substitution [𝑥𝑡𝑣]𝑡1 islegal.Proof: As 𝑡 is well-typed, 𝑡𝑣 is well-typed and from the corollary we get thatit’s closed. Now, because substitution with closed terms is legal, [𝑥𝑡𝑣]𝑡1 islegal.

By the preservation theorem, if we step a well-typed term, it remains well-typed. Therefore during evaluation, the top term remains well-typed. As long as we don’t reduce inside abstractions, substitutition only happens with closed values. This means that we can implement substitution with the naïve algorithm which saves us from quite a bit of hassle. In the next chapter we do exactly this, and implement call-by-value (CBV) semantics for STLC, which unlike full 𝛽-reduction, doesn’t reduce under abstractions.

Loading Exercise...

Footnotes

Footnotes

  1. Polymorphic lambda calculus a.k.a. System F, which is more flexible than STLC in many aspects, supports polymorphism. 𝕊𝕋𝕃++ implements System F at its core.