Gradual Typing

6.S050

\[ \newcommand{\target}[2]{#2} \newcommand{\type}[3]{#1 \target{type}{\vdash} #2 \target{type}{:} #3} \newcommand{\cast}[4]{#1 \target{type}{\vdash} #2 \Rightarrow #3 \target{type}{:} #4} \newcommand{\abs}[3]{\target{abs}{\lambda} #1 \target{abs}{:} #2\target{abs}{.} #3} \newcommand{\tabs}[2]{\target{tabs}{\Lambda} #1 \target{tabs}{.} #2} \newcommand{\arrow}{\target{arrowT}{\rightarrow}} \newcommand{\subt}{\target{subT}{<:}} \newcommand{\que}{\mathord{?}} \]

Static vs Dynamic typing

Pros of static typing:

  • Detects errors at compile time/gives early feedback
  • Documents behavior of code
  • Guaranteed absence of certain errors
  • Faster execution because dynamic checks are unnecessary

Pros of dynamic typing:

  • Expressiveness: not all correct programs are well-typed
  • Concision: type annotations are more code to maintain
  • Code changes require type changes/makes code harder to evolve
  • Sometimes type of value depends on runtime information (e.g. deserialization)

Gradual typing

  • Key Idea: Allow mixing typed and untyped code
    • Use types for error detection, documentation, code navigation
    • Allow dynamically typed code as well
  • Type system produces warnings, not errors
    • Maybe the program is correct
    • Maybe we didn’t encode enough of the types
  • Depending on your perspective:
    • combines the error prevention power of static types with the flexibility of dynamic types
    • combines the type annotation overhead of static types with the runtime performance of dynamic types
  • Increasing in popularity, particularly for dynamic languages
    • Maybe this means that static typing is better? Even dynamic languages want it?
    • On the other hand, people aren't switching away from Python and JavaScript

Typescript examples

let x : number = 0;
let y : number = 1;
console.log(x + y);
1
let x : number = 0;
let y : string = "string";
console.log(x - y);
../../../../../tmp/babel-1qn5Ml/ts-src-DLsw4Z.ts(3,17): error TS2363: The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
NaN
let x : number = 0;
let y : any = "string";
console.log(x - y);
NaN
function add(a: number, b: number): number {
    return a + b;
}
let x : number = 0;
let y : any = "string";
console.log(add(x, y));
0string

Takeaways from typescript examples

  • When the types are annotated, we get a warning (note that the code still ran) when we violate the type discipline
  • any tells the type system that a particular variable is always of the "right type", no matter the context
  • This leads to strange behavior when an any value passes through a typed interface

What should the semantics of this code be?

function add(a: number, b: number): number {
    return a + b;
}
function sub(a: number, b: number): number {
    return a - b;
}
let x : number = 0;
let y : any = "string"; // upcast string to any
console.log(sub(add(x, y), 2)); // downcast any to number
NaN

What do you think should happen here?

  1. This behavior (the code executing and returning NaN with no warnings) is fine.
  2. We should raise an exception when sub returns?
  3. We should raise an exception when add is called?

Levels of gradual typing

Jeremy Siek (inventor of gradual typing) describes 3 levels:

  1. No checking.
  2. Simple types are checked at implicit downcasts.
  3. Complex types (functions, objects, etc) are "checked" at implicit downcasts. (We actually keep track of casts on complex objects until a simple type violation occurs, then we assign blame to the appropriate complex cast.)

Soundness

  • what does it mean for a gradually typed program to be sound?
    • clearly it can still experience type errors
  • we say that a gradually typed program is sound if typed components can assume that their types accurately describe the values that they receive

Formalization

  • Start with simply typed lambda calculus (but we can extend these ideas to handle subtyping, references, etc)
  • Add any type ?
  • Examples of types:
    • \(\mathsf{Int} \rightarrow \que\)
    • \((\que \rightarrow \mathsf{Bool}) \rightarrow \mathsf{Int}\)
    • \(\mathsf{Int} \rightarrow (\que \rightarrow \que) \rightarrow \mathsf{Bool}\)

The grammar of types is as follows:

\begin{align*} e ::=&\quad c && \text{constant} \\ &|\quad x && \text{variable} \\ &|\quad e\ e &&\text{application} \\ &|\quad \lambda x : \tau.\ e &&\text{abstraction} \\ \tau ::=&\quad \mathsf{Int} && \text{integer type} \\ &|\quad \mathsf{Bool} && \text{boolean type} \\ &|\quad \tau \rightarrow \tau && \text{function type} \\ &|\quad \boxed{\que} && \text{any type} \end{align*}

Note that the only addition (on top of simple types) is the any type, which we denote as \(\que\).

Consistency

  • Consider a function \(f : (\que \rightarrow \mathsf{Bool}) \rightarrow \mathsf{Int}\)
    • What types can we pass as an argument to \(f\)?
      • \(\que \rightarrow \mathsf{Bool}\) (yes)
      • \(\mathsf{Int} \rightarrow \mathsf{Bool}\) (yes)
      • \(\mathsf{Int} \rightarrow \mathsf{Int}\) (no)
      • \(\mathsf{Int} \rightarrow \que\) (yes)
  • Formalize this as a relation \(\tau \sim \tau'\)
    • Reflexive (just like subtyping)
    • Symmetric (not like subtyping)
    • Not transitive. Why not?
      • \(\mathsf{Int} \sim \que\) and \(\que \sim \mathsf{Bool}\) but \(\mathsf{Int} \not \sim \mathsf{Bool}\)
\begin{gather} \tau \sim \tau\ \text{(C-Refl)} \quad \frac{\tau_{1} \sim \tau_{1}' \quad \tau_{2} \sim \tau_{2}'}{\tau_{1} \rightarrow \tau_{2} \sim \tau_{1}' \rightarrow \tau_{2}'}\ \text{(C-Fun)} \\[2ex] \tau \sim \que\ \text{(C-UnR)} \quad \que \sim \tau\ \text{(C-UnL)} \end{gather}

Type system

  • very few changes from simply typed lambda calculus (STLC)—only changes appear in application rules
    1. a function of any type can be applied to anything, producing any
      • why do we have to prove that \(e'\) has a type at all? if \(e'\) does not type, then we will reject the program
    2. functions with arrow type can be applied if their argument is compatible with their type
\begin{gather} \frac{\type{\Gamma, x:\tau}{e}{\tau'}}{\type{\Gamma}{(\abs{x}{\tau}{e})}{\tau \arrow \tau'}} \quad \text{(T-Abs)} \\[2ex] \boxed{\frac{\type{\Gamma}{e}{\que} \quad \type{\Gamma}{e'}{\tau'}}{\type{\Gamma}{e\ e'}{\que}}} \quad \text{(T-App-1)} \\[2ex] \boxed{\frac{\type{\Gamma}{e}{\tau'' \arrow \tau} \quad \type{\Gamma}{e'}{\tau'} \quad \tau \sim \tau''}{\type{\Gamma}{e\ e'}{\tau}}} \quad \text{(T-App-2)} \\[2ex] \frac{ x : \tau \in \Gamma }{ \type{\Gamma}{x}{\tau} } \quad \text{(T-Var)} \\[2ex] \frac{ c \text{ has type } \tau }{ \type{\Gamma}{c}{\tau} } \quad \text{(T-Const)} \end{gather}

Run-time semantics

  • remember the different levels of gradual typing?
  • if we erased the types, we'd get something like level 1—standard semantics for the lambda calculus, and sometimes our programs would get stuck
  • can we get type errors at runtime instead of getting stuck? what would that require?
  • we'll use our type system to decide where to insert runtime casts
    • a cast (written \(\langle \tau \rangle e\)) first evaluates \(e\) to \(v\), then checks that \(v\) is consistent with \(\tau\) (and potentially modifies/casts \(v\))
\begin{align*} e ::=&\quad c && \text{constant} \\ &|\quad x && \text{variable} \\ &|\quad e\ e &&\text{application} \\ &|\quad \lambda x : \tau.\ e &&\text{abstraction} \\ &|\quad \boxed{\langle \tau \rangle e} && \text{type cast} \\ \tau ::=&\quad \mathsf{Int} && \text{integer type} \\ &|\quad \mathsf{Bool} && \text{boolean type} \\ &|\quad \tau \rightarrow \tau && \text{function type} \\ &|\quad \que && \text{any type} \end{align*}

Cast insertion

  • Where do we need to insert runtime casts?
  • We'll insert them when we perform applications
    • this is the only place in the STLC where we have a type equality constraint (between function and argument type)
  • three cases of interest
    1. function is of any type: cast function so that it accepts the correct rhs type. returns any
      1. How can we check this cast? We can't determine that a function value has the right type (without precise type annotations that we don't have). Are we doing something wrong here?
        1. No. We'll see in the dynamic semantics that this cast gets pushed inside the function during evaluation
    2. function is of arrow type and argument is compatible but not equal: cast argument to correct type
    3. standard application: no casts
\begin{gather} \frac{\cast{\Gamma, x:\tau}{e}{e'}{\tau'}}{\cast{\Gamma}{\abs{x}{\tau}{e}}{\abs{x}{\tau}{e'}}{\tau \arrow \tau'}}\quad \text{(I-Abs)} \\[2ex] \frac{ x : \tau \in \Gamma }{ \cast{\Gamma}{x}{x}{\tau} } \quad \text{(I-Var)} \\[2ex] \frac{ c \text{ has type } \tau}{\cast{\Gamma}{c}{c}{\tau}} \quad \text{(I-Const)} \frac{ \cast{\Gamma}{e_{1}}{e_{1}'}{\que} \quad \cast{\Gamma}{e_{2}}{e_{2}'}{\tau_{2}} }{ \cast{\Gamma}{e_{1}\ e_{2}}{(\langle \tau_{2} \rightarrow \que \rangle e_{1}')\ e_{2}'}{\que} } \quad \text{(I-App-1)} \\[2ex] \frac{ \begin{gathered} \cast{\Gamma}{e_{1}}{e_{1}'}{\tau \rightarrow \tau'} \\ \cast{\Gamma}{e_{2}}{e_{2}'}{\tau_{2}} \quad \tau_{2} \neq \tau \quad \tau_{2} \sim \tau \end{gathered} }{ \cast{\Gamma}{e_{1}\ e_{2}}{e_{1}'\ (\langle \tau \rangle e_{2}')}{\tau'} }\quad \text{(I-App-2)} \\[2ex] \frac{ \cast{\Gamma}{e_{1}}{e_{1}'}{\tau' \arrow \tau} \quad \cast{\Gamma}{e_{2}}{e_{2}'}{\tau'} }{ \cast{\Gamma}{e_{1}\ e_{2}}{e_{1}'\ e_{2}'}{\tau} } \quad \text{(I-App-3)} \end{gather}

Runtime semantics

  • Extend our syntax of values with a special error value
  • Casts are checked at runtime using the type system (but we could also annotate values with their type)
  • When a cast fails, we emit the error value, which propagates up

Runtime (values)

\begin{align*} v ::=&\quad c && \text{constant} \\ &|\quad x && \text{variable} \\ &|\quad \lambda x : \tau.\ e &&\text{abstraction} \\ % v ::=&|\quad s && \text{simple values} \\ % &|\quad \langle \que \rangle s && \text{boxed value} \\ \varepsilon ::=&\quad \mathsf{CastError} && \text{failed cast} \\ r ::=&\quad \varepsilon ~|~ v && \text{result} \end{align*}

Runtime semantics (casting)

\begin{gather} \frac{ e \Downarrow v \quad \type{\emptyset}{v}{\mathsf{Int}} } { \langle \mathsf{Int} \rangle e \Downarrow v } \quad \text{(E-Cast-Int)} \\[2ex] \frac{ \begin{gathered} e \Downarrow v \quad \type{\emptyset}{v}{\tau_2 \rightarrow \tau_2'} \\ \tau_2 \rightarrow \tau_2' \sim \tau_1 \rightarrow \tau_1' \quad x\ \text{is fresh} \end{gathered} } { \langle \tau_1 \rightarrow \tau_1' \rangle e \Downarrow \lambda x: \tau_1.\ (\langle \tau_1' \rangle (v (\langle \tau_2 \rangle x))) } \quad \text{(E-Cast-Func)} \\[2ex] \frac{ e \Downarrow v } { \langle \que \rangle e \Downarrow v } \quad \text{(E-Cast-Any)} \\[2ex] \frac{ e \Downarrow v \quad \type{\emptyset}{v}{\tau'} \quad \tau \not \sim \tau' } { \langle \tau \rangle e \Downarrow \mathsf{CastError} } \quad \text{(E-Cast-Err)} \\[2ex] \end{gather}

Runtime semantics (functions & constants)

\begin{gather} \lambda x:\tau.\ e \Downarrow \lambda x:\tau.\ e \quad \text{(E-Abs)} \\[2ex] \frac{ e \Downarrow \lambda x:\tau.\ e'' \quad e' \Downarrow v' \quad e''[v'/x] \Downarrow v } { e\ e' \Downarrow v } \quad \text{(E-App)} \\[2ex] c \Downarrow c \quad \text{(E-Const)} \end{gather}

Runtime semantics (error propagation)

\begin{gather} \frac{e \Downarrow \varepsilon}{\langle \tau \rangle e \Downarrow \varepsilon} \quad \text{(E-Cast-Prop)} \\[2ex] \frac{e \Downarrow \varepsilon}{e\ e' \Downarrow \varepsilon} \quad \text{(E-App-Prop-1)} \\[2ex] \frac{e \Downarrow \lambda x:\tau.\ e'' \quad e' \Downarrow \varepsilon}{e\ e' \Downarrow \varepsilon} \quad \text{(E-App-Prop-2)} \\[2ex] \end{gather}

Worked example

  • \((\lambda f : \que \rightarrow bool.\ not\ (f 1))\ (\lambda x: int.\ iszero\ x)\)
  • first, cast insertion
    • first, insert on lhs
      • \((\lambda f : \que \rightarrow bool.\ not\ (f (\langle \que \rangle 1)))\)
    • next, insert on rhs
      • nothing needs to be done
    • finally, insert on application
    • what is the type of the lhs? \(not : bool \rightarrow bool\), \(iszero : int \rightarrow bool\)
      • \((\que \rightarrow bool) \rightarrow bool\)
    • what is the type of the rhs?
      • \(int \rightarrow bool\)
    • which rule matches? I-App-2
    • therefore, insert cast \((\lambda f : \que \rightarrow bool.\ not\ (f (\langle \que \rangle 1)))\ (\langle \que \rightarrow bool \rangle (\lambda x: int.\ iszero\ x))\)
  • now for evaluation
    • \((\lambda f : \que \rightarrow bool.\ not\ (f (\langle \que \rangle 1)))\ (\langle \que \rightarrow bool \rangle (\lambda x: int.\ iszero\ x))\)
    • \((\lambda f : \que \rightarrow bool.\ not\ (f (\langle \que \rangle 1)))\ (\lambda y:\que.\ \langle bool \rangle((\lambda x: int.\ iszero\ x)\ (\langle int \rangle y)))\)
    • \(not\ ((\lambda y:\que.\ \langle bool \rangle((\lambda x: int.\ iszero\ x)\ (\langle int \rangle y)))\ 1)\)
    • \(not\ (\langle bool \rangle((\lambda x: int.\ iszero\ x)\ (\langle int \rangle 1)))\)
    • \(not\ (\langle bool \rangle((\lambda x: int.\ iszero\ x)\ 1))\)
    • \(not\ (\langle bool \rangle(iszero\ 1))\)
    • \(not\ (\langle bool \rangle false)\)
    • \(not\ false\)
    • \(true\)
  • \((\lambda x: \que.\ iszero\ x)\ true\)
    • cast insertion
      • \((\lambda x: \que.\ iszero\ (\langle int \rangle x))\ (\langle \que \rangle true)\)
    • eval
      • \((\lambda x: \que.\ iszero\ (\langle int \rangle x))\ (\langle \que \rangle true)\)
      • \((\lambda x: \que.\ iszero\ (\langle int \rangle x))\ true\)
      • \(iszero\ (\langle int \rangle true)\)
      • \(iszero\ CastError\)
      • \(CastError\)

In practice

  • getting the soundness guarantee costs performance (sometimes a lot of performance)
  • why? insertion of runtime type checks
  • unsound gradual typing does not pay this cost, so this might explain why typescript chose not to check types
    • (also, the impact of type errors on dynamic languages already low)
  • some of this overhead is caused by extensive runtime checks
gradual_typing_slowdown.png

Source: "Is sound gradual typing dead?" Takikawa et al., 2016

References & Further Reading

Last updated: 2023-05-02 Tue 18:52