Gradual typing is a type system I developed with Walid Taha in 2006 that allows parts of a program to be dynamically typed and other parts to be statically typed. The programmer controls which parts are which by either leaving out type annotations or by adding them in. This article gives a gentle introduction to gradual typing.
The following were our motivations for developing gradual typing:
Before describing gradual typing, let’s review dynamic and static type checking. Dynamic type checking
A number of popular languages, especially scripting languages, are
dynamically typed. Examples include Perl, Python, Javascript, Ruby,
and PHP. In general, a type is something that describes a set of
values that have a bunch of operations in common. For example, the
type int describes the set of (usually 32 bit) numbers that support
operations like addition, subtraction, etc. A type error is the
application of an operation to a value of the wrong type. For example,
applying concatenation to an integer would be a type error in a
language where concatenation is an operation only on strings. Another
example of a type error is invoking a method on an object that doesn’t
implement the method, such as car.fly()
. (Isn’t it a shame that flying
cars have not yet hit the mainstream, and it’s well past the year
2000!) The precise definition of type error is programming language
dependent. For example, one language might choose to allow
concatenation of integers and another language not. In a dynamically
typed language, type checking is performed during program execution,
immediately before the application of each operation, to make sure
that the operand type is suitable for the operation.
The following is an example Python program that results in a type error.
def add1(x):
return x + 1
class A(object):
pass
a = A()
add1(a)
The output from running the above program on the standard Python interpreter is
TypeError: unsupported operand type(s) for +: 'A' and ‘int'
There are also a number of popular statically checked languages, such as Java, C#, C and C++. In a statically checked language, some or even all type errors are caught by a type checker prior to running the program. The type checker is usually part of the compiler and is automatically run during compilation.
Here’s the above example adapted to Java.
class A {
int add1(int x) {
return x + 1;
}
public static void main(String args[]) {
A a = new A();
add1(a);
}
}
When you compile this class, the Java compiler prints out the following message.
A.java:9: add1(int) in A cannot be applied to (A)
add1(a);
^
1 error
You may wonder, how can a type checker predict that a type error will occur when a particular program is run? The answer is that it can’t. It is impossible to build a type checker that can predict in general which programs will result in type errors and which will not. (This is equivalent to the well-known halting problem.) Instead, all type checkers make a conservative approximation of what will happen during execution and give error messages for anything that might cause a type error. For example, the Java compiler rejects the following program even though it would not actually result in a type error.
class A {
int add1(int x) {
return x + 1;
}
public static void main(String args[]) {
A a = new A();
if (false)
add1(a);
else
System.out.println("Hello World!");
}
}
The Java type checker does not try to figure out which branch of an if statement will be taken at runtime. Instead it conservatively assumes that either branch could be taken and therefore checks both branches.
There is a religious war between people who think dynamic checking is better and people who think static type checking is better. I believe that one of the reasons why this war has gone on for so long is that both groups have good points. (They also have some not-so-good points.) Unfortunately the two groups typically don’t acknowledge the good points made by the other group as being good points. My evaluation of the points, given below, will probably annoy both the static typing fans and the dynamic typing fans. There are of course arguments to be made for or against each of the points, and the evaluation below shows where I land after considering the arguments.
Because neither static or dynamic type checking is universally better than the other, it makes sense to provide the programmer a choice, without forcing them to switch programming languages. This brings us to gradual typing.
A gradual type checker is a type checker that checks, at compile-time, for type errors in some parts of a program, but not others, as directed by which parts of the program have been annotated with types. For example, our prototype gradual type checker for Python does not give an error for the above program, reproduced again below.
def add1(x):
return x + 1
class A:
pass
a = A()
add1(a)
However, if the programmer adds a type annotation for the parameter x, as follows, then the type checker signals an error because the type of variable a is A, which is inconsistent with the type of parameter x of the add1 function, which is int.
def add1(x : int):
return x + 1
class A:
pass
a = A()
add1(a)
(Our rules for assigning static types to local variables such as a are somewhat complicated because Python does not have local variable declarations but in most cases we give the variable the same type as the expression on the right-hand side of the assignment.)
The gradual type checker deals with unannotated variables by giving
them the unknown type (also called the dynamic type in the
literature), which we abbreviate as ?
and by allowing implicit
conversions from any type to ?
and also from ?
to any other type. For
simplicity, suppose the + operator expects its arguments to be
integers. The following version of add1 is accepted by the gradual
type checker because we allow an implicit conversion from ?
(the type
of x) to int (the type expected by +).
def add1(x):
return x + 1
Allowing the implicit converson from ?
to int is unsafe, and is what
gives gradual typing the flavor of dynamic typing. Just as with
dynamic typing, the argument bound to x will be checked at run-time to
make sure it is an integer before the addition is performed.
As mentioned above, the gradual type checker also allows implicit
conversions from any type to type ?
. For example, the gradual type
checker accepts the following call to add1 because it allows the
implicit conversion from int (the type of 3) to ?
(the implied type
annotation for parameter x).
add1(3)
The gradual type checker also allows implicit conversions between more
complicated types. For example, in the following program we have a
conversion between different tuple types, from ? * int
to int * int
.
def g(p : int * int):
return p[0]
def f(x, y : int):
p = (x,y)
g(p)
In general, the gradual type checker allows an implicit conversion between two types if they are consistent with each other. We use the shorthand S ~ T to express that type S is consistent with type T. Here are some of the rules that define when two types are consistent:
? ~ T
and T ~ ?
.B ~ B
.T1 * T2 is
consistent with another tuple type S1 * S2
if T1 ~ S1
and T2 ~ S2
. This rule generalizes in a straightforward
way to tuples of arbitrary size.fun(T1,...,Tn,R)
(the T1…Tn
are the parameter types
and R
is the return type) is consistent with another function type
fun(S1,...,Sn,U)
if T1 ~ S1
…Tn ~ Sn
and R ~ U
.We write S !~ T
when S is not consistent with T.
So, for example
int ~ int
int !~ bool
? ~ int
bool ~ ?
int * int ~ ?
fun(?,?) ~ fun(int,int)
? ~ fun(int,int)
int * int !~ ? * bool
Gradual typing allows an implicit cast from any type to ?
, similar to
object-oriented type systems where Object is the top of the subtype
lattice. However, gradual typing differs in that it also allows
implicit casts from ?
to any other type. This is the distinguishing
feature of gradual typing and is what gives it the flavor of dynamic
typing. Previous attempts at mixing static and dynamic typing, such as
Thatte’s Quasi-static Typing, tried to use subtyping but had to deal
with the following problem. If the dynamic type is treated as both the
top and the bottom of the subtype lattice (allowing both implicit
up-casts and down-casts), then the lattice collapses to one point
because subtyping is transitive. In other words, every type is a
subtype of every other type and the type system no longer rejects any
program, even ones with obvious type errors.
Consider the following program.
def add1(x : int) -> int:
return x + 1
add1(true)
Using true as an argument to the function add1 is an obvious type
error but we have bool <: ?
and ? <: int
, so bool <: int
. Thus the
subtype-based type system would accept this program. Thatte partially
addressed this problem by adding a post-pass, called plausibility
checking, after the type checker but this still did not result in a
system that catches all type errors within fully annotated code, as
pointed out by Oliart. I won’t go into the details of Thatte’s
plausibility checking, as it is rather complicated, but I will discuss
an example. The following program has an obvious static type error
which is not detected by plausibility checking.
def inc(x: number):
return x + 1
inc(True)
In the application of inc to True, both inc and True can be implicitly
up-cast to the dynamic type ?
. Then inc is implicitly down-cast to
? -> ?
. The plausibility checker looks for a greatest lower bound of
number -> number
and ? -> ?
, which is ? -> number
, so it lets this
program pass without warning.