Type Checking
PPL 2023
This lecture introduces type checking and type inference with illustration in TypeScript.
- Introduce optional type declarations as illustrated in TypeScript
- Define the type language defined in TypeScript - complex type definitions, named types, implicit types, recursive types, generic types
- Define function types
- Define closures and their types
- Define type compatibility and type checking:
- Distinguish structural typing and nominal typing
Why Types
Adding types to a program has three key advantages:
- It allows the compiler to detect errors that would otherwise only be detected at runtime. It is much better to detect errors as early as possible in the development cycle.
- It serves as excellent documentation by reflecting the intention of the programmer.
- More importantly, it helps the programmer model the solution she is designing. A good typing system drives programs towards systematic models - both for data and for code.
Adding types is sometimes “annoying” because it can make programs longer, and for complicated types or generic types - it can make simple programs complex. For example, a function such as x => x
(known as the identity function) has a complex type (because it can work on any possible types). We will see further examples of this later.
TypeScript alleviates these 2 problems by:
- Making type annotations optional.
- Making type annotations implicit (that is, where the type of expressions can be inferred from the code, it will be).
This approach is called gradual typing. As a general strategy in programming languages, gradual typing allows programmers to trade-off prototyping and type safety.
Runtime Errors Caused by Unexpected Values
Consider the following type definition and the function operating over values in this type:
import { map } from "ramda";
type Date = {
year: number;
month: number;
day: number;
}
type Person = {
birthDate: Date;
name: string;
}
const computeAges = (persons) => map(p => 2023 - p.birthDate.year, persons);
let persons = [
{ name: "avi", birthDate: { year: 1992, month: 7, day: 10 } },
{ name: "batia", birthDate: { year: 1996, month: 3, day: 2 } }
];
console.log(computeAges(persons)); // ==> [ 31, 27 ]
Consider now what happens if we invoke computeAges
on this value:
computeAges([{ name: "avi" }, { name: "batia" }]);
// ==> Uncaught TypeError: Cannot read properties of undefined (reading 'year')
We obtain a runtime error when we try to access a property of a map which is not provided in the actual value passed to the function.
In other words, there is an incompatibility between the value passed to the function and the type of the value expected by the function.
Type annotations are introduced in programming languages to enable type checking. Type checking is a process that analyzes a program and verifies that such incompatibities between variables or function parameters and the values to which they are bound at runtime are impossible for all possible executions of the program.
When type checking is performed at compilation, it detects errors that could otherwise occur later at runtime. This is a much better situation - errors caught early are errors that are avoided. Errors caught at runtime cause damage.
Compilers that implement type checking provide an extremely powerful form of program verification.
If we change our function declaration as follows, with a type declaration for the parameter of the function
computeAges
as persons: Person[]
, the compiler can verify that the invocation of the function can lead
to a runtime type error, and apparently it is not the behavior intended by the programmer. In this case,
compilation fails and a helpful compilation error message is provided.
import { map } from "ramda";
type Date = {
year: number;
month: number;
day: number;
}
type Person = {
birthDate: Date;
name: string;
}
const computeAges = (persons: Person[]) => map(p => 2023 - p.birthDate.year, persons);
let persons = [
{ name: "avi", birthDate: { year: 1992, month: 7, day: 10 } },
{ name: "batia", birthDate: { year: 1996, month: 3, day: 2 } }
];
console.log(computeAges(persons)); // ==> [ 31, 27 ]
computeAges([{ name: "avi" }, { name: "batia" }]);
// --> Compile error: Property birthDate is missing in type {name: string} but required in type Person.
Why Would Anyone Give Up on Type Checking?
Given that type checking is such a powerful tool - why would any programmer want to give up this and use a programming language that does not provide it?
The arguments usually advanced by proponents of languages without type checking are the following:
-
Conciseness: Programs without type annotations are more concise.
Conciseness is important because it leads to more readability and better understanding.For example, the function above with full types looks like:
const computeAges: (person: Person[]) => number[] = (persons) => map(p => 2022 - p.birthDate.year, persons);
This argument is weak - because the additional type annotation is in fact a form of documentation which clarifies the intention of the programmer. In addition, in TypeScript, one can skip some of the types when they can be easily inferred by the compiler - making this argument unacceptable altogether.
For example, consider the untyped function:
const add = (x, y) => x + y;
In Javascript, the
+
operator works on a wide range of values (number, strings, arrays). It often produces unexpected output for example:[1, 2] + ["a", "b"]; // ==> '1,2a,b'
If a programmer declares that the intention of this function is to operate only over number, it clarifies the way the function can be tested and what it means for the function to be correct:
const add: (x: number, y: number) => number = (x, y) => x + y;
-
Untyped languages are easier to adjust to incremental changes. For example, consider a program that operates over values of type
Person
as described above. If later, after the program has been put in production, we start introducing additional data for some persons - so that we add a new propertyaddress
. A program in an untyped language can be patched incrementally to support such values - by adding runtime checks for the presence of the additional property. Such patching would require much more in-depth modifications in a statically type-checked language. -
Untyped languages encourage interactive programming. When programmers interact with an interpreter, interactively, try a version of a function, then revise it, run tests, modify the structure of the values, revise the functions in fast experimentation cycles, with no compilation and direct interpretation - untyped languages allow faster experimentation. The counter argument is once the programmer has completed the experimentation / prototyping phase, the second stage of cleaning up with type definition can only improve the quality of the program and its design. Gradual typing is attractive because it supports both types of operations and transition from prototype to typed correct versions.
There are other arguments in favor of untyped languages which we will not develop further that are related to the type of polymorphism that is encouraged by each style. In this course, we strongly adopt the typed style of programming (we have chosen our camp :-) ).
Types in TypeScript
TypeScript adds optional type declarations to JavaScript.
The principles of this addition are:
- Type declarations are optional. If they are present, they are checked, otherwise no check is performed. This means that regular JavaScript with no type annotations at all are valid TypeScript expressions.
- The TypeScript compiler (
tsc
) performs two tasks:- It translates a TypeScript program into a JavaScript program
- It checks that the program satisfies all the type declarations specified in the program.
- Type annotations can be implicit and inferred by the TypeScript compiler.
// The following TypeScript expression
let add: (a: number, b: number) => number = (a, b) => a + b;
add(1, 2);
// is translated by tsc into:
let add = (a, b) => a + b;
add(1, 2);
Type Annotations
Type annotations are optional in TypeScript. They can occur in the following contexts:
// After variable declarations
let varName: <typeAnnotation>;
// As part of a function signature
(param: <typeAnnotation>, ...) => ...
function fname(param : <typeAnnotation>, ...): <typeAnnotation> { ... }
Type annotation are written in a specific form - which is called the type language. The type language is used to describe the expected type of variables, parameters or the value returned by functions.
Type Expression Denotation
In general, all type expressions in the type language are used to refer to a specific set of values. The relation between a type expression and a set of values to which it corresponds is called denotation. (The denotation relation is in general the relation that exists between a name and the object to which the name refers.)
Atomic Type Expressions
The simplest type language expression refers to a primitive type - for example number
, boolean
, string
.
It is used as follows:
let x: number = 2;
let a: string = "a";
let b: boolean = true;
b; // ==> true
These atomic type expressions denote the primitive sets of values that are defined in JavaScript (set of all number values, set of all string values, set of a boolean values).
More complex type language expressions are needed to describe types over compound values. These compound type expressions are constructed recursively by combining smaller type expressions
Array Type Expressions
To describe a homogeneous array, the following notation is used: <typeAnnotation>[]
let stringArr: string[] = ["a", "b", "c"];
let s: string = stringArr[0];
s; // ==> 'a'
The following values are not compatible with this type annotation:
stringArr = ['a', 1] // Item 1 has wrong type
stringArr[0] = true; // Item 0 has wrong type
Map Type Expressions
To describe map types, the following notation is used:
{ <key>: <typeAnnotation>; ... }
let s: { name: string; cs: boolean; age: number } = {
name: "avi",
gender: "m",
cs: true,
age: 22
};
s; // ==> { name: 'avi', gender: 'm', cs: true, age: 22 }
The expected meaning of the map type description is that all the keys in the annotation must be present in the value with the described type.
There could be more keys as illustrated in the assignment s = s2
below - but all the keys that are specified in the type must be present.
Note, however, that when we bind a variable to a literal expression, the TypeScript compiler demands that the type of the literal be exactly that of the variable - with no extension.
let s: { name: string; cs: boolean; age: number } = {
name: "avi",
cs: true,
age: 22
};
s = { age: 22, name: "avi", cs: true }; // OK - The order of keys is not important
s = { name: "avi", age: 22 }; // Error: missing key "cs"
s = { name: "avi", age: "22", cs: true }; // Error: key "age" must be a string
s = { name: "avi", age: 22, cs: true, gender: "m" }; // NOT OK for a literal because of additional key "gender"
let s2: { name: string; cs: boolean; age: number; gender: string } = {
name: "avi",
age: 22,
cs: true,
gender: "m"
};
s = s2; // OK even though s2 has more properties - it satisfies the requirements of s's type.
s; // ==> { name: 'avi', age: 22, cs: true, gender: 'm' }
Map Types Relationships
The map type {}
denotes all possible map values.
The map type { a: string }
denotes all maps that have a key a
with a string
value - for example:
{ a: "x" }
{ a: "y", b: 1 }
and the following values are not part of this type:
{}
{ b: 1 }
{ a: 1 }
A map type { a: string, b: number }
denotes all maps that have a key a
with a string
value and a key b
with a number
value.
We infer from this definition that the type { a: string, b: number }
is a strict sub-type of the type { a: string }
.
What is the relation between the types?
type mapAB = { a: string, b: number };
type mapAC = { a: string, c: boolean };
These two types are distinct:
{ a: "x", b: 2 }
is in mapAB
and not in mapAC
.
{ a: "x", c: true }
is in mapAC
and not in mapAB
.
Yet - they have a non-empty overlap:
{ a: "x", b: 2, c: true }
is both in mapAB
and in mapAC
.
In general - the intersection of the types mapAB
and mapAC
is the type:
type mapABC = { a: string, b: number, c: boolean };
The following types are disjoints:
type mapAS = { a: string };
type mapAN = { a: number };
because the constraints on the value of the key a
are incompatible.
A good way to think about these relations is the following:
- Each
key: type
component of the map type specification is a constraint on the values that belong to the type. - The more constraints are specified in a type, the less values belong to the type.
- If you join all the constraints in two map type specifications - you obtain a type specification which denotes the intersection of the types (as long as the constraints are compatible).
Named Type Expressions
Type expressions can be given names. For example, a map type expression can be named using the type
alias construct:
type <typeName> = {
<key>: <typeAnnotation>;
...
}
type Student = {
name: string;
cs: boolean;
age: number;
}
let s: Student = { name: "avi", cs: true, age: 22 };
type stringArray = string[];
s; // ==> { name: 'avi', cs: true, age: 22 }
Embedded Type Expressions
A type expression can have arbitrary type expressions embedded recursively. For example:
type Course = {
courseName: string;
dept: string;
semester: number;
year: number;
};
type RegisteredStudent = {
name: string;
cs: boolean;
age: number;
courses: Course[];
};
// This is a valid JSON value for the RegisteredStudent type
let rs: RegisteredStudent = {
name: "avi",
cs: true,
age: 12,
courses: [{ courseName: "ppl", dept: "cs", semester: 2, year: 2021 }]
};
// This is NOT a valid JSON value for the RegisteredStudent type:
let badRs: RegisteredStudent = {
name: "avi",
cs: true,
age: 12,
courses: [{ courseName: "ppl", dept: "cs", year: 2021 }] // badRs.courses[0] is missing key semester.
};
rs;
// ==>
// {
// name: "avi",
// cs: true,
// age: 12,
// courses: [{ courseName: "ppl", dept: "cs", semester: 2, year: 2021 }]
// }
The TypeScript compiler (tsc
) emits this error:
Property 'semester' is missing in type '{ courseName: string; dept: string; year: number; }' but required in type 'Course'.
Implicit Type Annotations
TypeScript can infer the type of variables based on their usage. This is called an implicit type annotation. For example:
let a = 1;
a = 'nok'; // type error
On the assignment line a = 'nok'
, tsc
emits this error:
Type 'string' is not assignable to type 'number'.
This means that tsc
detected that the variable a
is of type number
based on its bound value 1
.
From this point on, it checks that a
keeps a number
value.
Type Queries
One can use the following notation to declare that a variable should have the type of another variable:
let p1 = { name: "avi", age: 22 };
let p2: typeof p1 = { name: "nok" };
In this case, tsc
emits this error:
Property 'age' is missing in type '{ name: string; }' but required in type '{ name: string; age: number; }'.
TypeScript detects that the type of p1
is { name: string, age: number; }
on the basis of the value of p1
- even though the type of p1
was not declared - it was inferred from its value.
The typeof var
construct is called a type query in TypeScript.
NOTE:
The typeof p1
type query annotation is different from the typeof
operator we discussed in the previous lecture - which is a primitive runtime operator in JavaScript.
The TypeScript typeof
is a type annotation - it belongs to the Type Language - its value is a type annotation.
The JavaScript typeof
is a runtime primitive operator. Its value is a string.
// JavaScript typeof
const p3 = 2;
typeof p3; // ==> 'number'
Recursive Types
Consider the case of defining a binary tree. Using JSON, we would encode a binary tree as follows:
let binTree = {
root: 1,
left: { root: 2 },
right: { root: 3 }
};
binTree; // ==> { root: 1, left: { root: 2 }, right: { root: 3 } }
The TypeScript type that corresponds to this value would be:
{
root: number;
left: ?;
right: ?;
}
We would need to specify that the left
and right
keys expect to receive values of exactly the same type.
The only way to achieve such a declaration is to use a recursive type declaration – which requires us to
give a name to the type:
type BinTree = {
root: number;
left: BinTree;
right: BinTree;
}
Let us try to create a value according to this type specification:
type BinTree = {
root: number;
left: BinTree;
right: BinTree;
}
let bn: BinTree = {
// DOES NOT PASS TYPE CHECKING
root: 1,
left: { root: 2 },
right: { root: 3 }
};
Unfortunately, this value is not well typed - tsc
returns this error:
Type '{ root: number; }' is missing the following properties from type 'BinTree': left, right
That is - we are missing the keys left
and right
for the nodes with root 2 and 3.
In other words, we cannot stop the recursion - the value must keep unrolling to be a valid BinTree
.
There are multiple solutions to this problem. One is to indicate that the properties left
and right
are optional. This is indicated using the ?
syntax as follows:
type BinTree = {
root: number;
left?: BinTree;
right?: BinTree;
}
let bn: BinTree = {
root: 1,
left: { root: 2 },
right: { root: 3, right: { root: 4 } }
};
bn;
// ==>
// { root: 1,
// left: { root: 2 },
// right: { root: 3, right: { root: 4 } } }
Other solutions rely on type unions which we will review later in the course.
NOTE:
How different is the type annotation: { a?: string, b: number }
from: { a: any, b: number }
?
Answer: the difference is that a: any
means that the property a
can have any possible value;
in contrast a?: string
means that either a
does not occur at all or if it occurs it must have a value of type string
.
Generic Types
If we want to define a binary tree whose nodes can have any type - but where all the nodes in the tree must have the same type, we must introduce type variables.
type BinTree<T> = {
root: T;
left?: BinTree<T>;
right?: BinTree<T>;
}
let bn: BinTree<number> = {
root: 1,
left: { root: 2 },
right: { root: 3 }
};
bn; // ==> { root: 1, left: { root: 2 }, right: { root: 3 } }
If we want to use a heterogeneous tree (a tree that can contain nodes of different types), we can use the special type called any
:
let an: BinTree<any> = {
root: "different",
left: { root: 1, left: { root: false } }
};
an; // ==> { root: 'different', left: { root: 1, left: { root: false } } }
The any
type is compatible with all values - it denotes the set of all possible values.
Every type that can be defined is actually a subset of the any
type.
In itself, any
is not useful - typing a variable as any
does not indicate any constraint on the variable.
But when used as a type parameter in a complex type definition as above, any
becomes useful.
When Are Generic Types Useful?
Generic types are compound type expressions with type variables. Some of their components are thus left as unspecified types. When are such partially unspecified types useful?
- One reason to define a generic type is to enable writing generic functions that operate over all possible instantiations of the generic type in the same manner. For example, given a
BinTree
value, one can define generic functions such astreeNodesNumber
ortreeDepth
which compute meaningful values over the value in exactly the same way regardless of the type of the node. There would be no reason to write specific functions for aBinTreeOfString
and aBinTreeOfNumber
because the same algorithm works on these values.
const treeNodesNumber: <T>(t: BinTree<T>) => number = t => {
if (t === undefined) return 0;
else return 1 + treeNodesNumber(t.left) + treeNodesNumber(t.right);
};
treeNodesNumber({ root: 1, left: { root: 2 }, right: { root: 3 } }); // ==> 3
const treeDepth: <T>(t: BinTree<T>) => number = t => {
if (t === undefined) return 0;
else return 1 + Math.max(treeDepth(t.left), treeDepth(t.right));
};
treeDepth({
root: 1,
left: { root: 2, left: { root: 4 } },
right: { root: 3 }
}); // ==> 3
- Another reason to use generic types is when we need to define a set of value that is a combination between two types that are dependent of each other. For example, consider the set of values which represent an array of values together with the minimum of the values that appear in the array. Such data structure can be defined for any type
T
which denotes a set of values over which an order relation exists.
We can define it as generic type as follows:
type ArrayMin<T> = {
min: T;
values: T[];
}
let numberArrayMin: ArrayMin<number> = {
min: 2,
values: [6, 2, 4]
};
let stringArrayMin: ArrayMin<string> = {
min: "avi",
values: ["bibi", "charles", "avi"]
};
stringArrayMin.min; // ==> 'avi'
The relation between the two components of these values is the same - regardless of the actual type (number, string) of the components.
Naturally, the two motivations for using generic types combine well together - generic algorithms work well over dependent components.
Generic Types Relationships
What is the type relationship (inclusion, disjointness) between BinTree<T>
and BinTree<number>
?
We first need to realize that the type expression BinTree<T>
cannot be used as the type of a variable if it is not in the scope of a type variable. That is, the expression:
let b : BinTree<T> = ...;
is not possible - because BinTree<T>
cannot be in itself the type of a variable.
A generic type can only appear in the context of a generic function (like in the example treeDepth
and treeNodesNumber
above) or in the context of a larger generic type.
When the tree variable is instantiated to a concrete (non-generic) type, then the type can be used as any other type:
let st : BinTree<string> = { root: "avi", left: { root: "bibi" } };
The relation between a generic type such as BinTree<T>
and a type such as BinTree<string>
is called instantiation.
Now, what is the relation between BinTree<string>
and BinTree<number>
?
These two concrete types are instances of the same generic type.
Any value in BinTree<string>
has a property root
of type string
.
Any value in BinTree<number>
has a property root
of type number
.
We infer that these two types are disjoint.
Note that while these two types are disjoint, we still can write generic functions that operate of values of either type using the same code.
If a type S
is a subtype of a type T
, then what is the relation between the types BinTree<S>
and BinTree<T>
? For example, consider T = { name: string }
and S = { name: string, age: number }
.
To answer this question, we can read the definition of BinTree<T>
as a set of constraints a value must meet to belong to the type this type expression denotes:
- The value must be a map value
- A property
root
must be present and have a value of typeT
- which means that it must have a propertyname
of typestring
. - A property
left
can either be absent or present and of typeBinTree<T>
- A property
right
can either be absent or present and of typeBinTree<T>
.
Knowing that any value of type S
is also a value of type T
(by definition of subtyping), we can infer that any value in BinTree<S>
meets all the constraints to also belong to BinTree<T>
.
In general, if S
is a subtype of T
, then BinTree<S>
is a subtype of BinTree<T>
.
Function Types
One can type functions in TypeScript. Let us introduce function types step by step:
An untyped function in JavaScript has the following form:
// Named function
function add(x, y) {
return x + y;
}
// Anonymous function
const myAdd = function (x, y) {
return x + y;
};
// Using the fat arrow notation:
const myFatAdd = (x, y) => x + y;
console.log(myFatAdd(2, 3)); // ==> 5
We can first specify the types of the parameters and the return type, in a way similar to the way it would be done in Java. This applies both to named functions and to anonymous functions.
// Named function
function add(x: number, y: number): number {
return x + y;
}
// Anonymous function
const myAdd = function (x: number, y: number): number {
return x + y;
};
// Using the fat arrow notation:
const myFatAdd = (x: number, y: number): number => x + y;
console.log(myFatAdd(2, 3)); // ==> 5
Let us now write the full type of the function out of the function value:
let myAdd: (x: number, y: number) => number = function (x, y) {
return x + y;
};
let myFatAdd: (x: number, y: number) => number = (x, y) => x + y;
console.log(myFatAdd(3, 4)); // ==> 7
The type expression:
(x: number, y: number) => number
is a function type. The values that this type denotes are functions that map a pair of numbers to a number - in other words, functions whose domain is within \(Number \times Number\) and whose range is within \(Number\). (Remember that types denote a set of values.)
This notation is called the function signature - it combines the information on the type of the parameters, their name and the type of the returned value.
Parameter names are just to help with readability. We could have instead written:
let myAdd: (baseValue: number, increment: number) => number = function (x: number, y: number): number {
return x + y;
};
As long as the parameter types align, it’s considered a valid type for the function, regardless of the names you give the parameters in the function type.
The second part of the function type is the return type. We make it clear which is the return type by using a fat arrow (=>
) between the parameters and the return type. This is a required part of the function type, so if the function doesn’t return a value (which means this is a function that just has a side-effect - no return value), we use the special type void
instead of leaving it off.
Closures and their Type
Consider the following function definition:
let z = 10;
let add: (x: number, y: number) => number = (x, y) => x + y + z;
add(1, 2); // ==> 13
The definition of add
refers to the variable z
, which is defined outside the body of the function.
When this happens, we say that the function captures the variable z
- and the value of the function add
is called a closure. This is because the function closes the captured variables together with the function definition.
The body of the function depends on the variable z
- yet, the signature of the function does not indicate this dependency.
Consider the following example that indicates why this capture mechanism is part of the definition of the closure:
let adder = function (inc) {
return x => x + inc;
};
let a5 = adder(5);
let a2 = adder(2);
console.log(a5(10)); // ==> 15
console.log(a2(10)); // ==> 12
adder(10)(3); // ==> 13
Let us analyze the defintion of the function adder
- and refactor it step by step using types.
adder
is a function that accepts one parameter inc
. What should be the type of inc
?
We look at where inc
is used in the body of the function adder
and find the expression x + inc
.
This expression appies to numbers - we conclude that z
should be a number
.
The signature of the function adder
should therefore look like:
(inc: number) => ?
What is the type of the value returned by adder
?
We analyze the body of adder
to identify the returned expression:
function (inc) {
return x => x + inc;
}
The returned value is x => x + inc
.
This expression is a function - which receives x
as a parameter and return x + inc
as a parameter.
The type of this returned function is thus:
(x: number) => number
Putting parameter and return value together, we obtain the type of the function adder
as this expression:
(inc: number) => ((x: number) => number)
And adding all the types in the definition of adder
we obtain:
let adder: (inc: number) => (x: number) => number = function (inc: number) {
return (x: number): number => x + inc;
};
Now consider the capturing of the parameter z
when the function adder
is invoked - in the expression:
let add5 = adder(5);
The type of add5
is (x: number) => number
. adder(5)
returns a function of one parameter.
When it is invoked, this function adds 5 to its parameter.
How does it know to add 5 specifically?
This is because when the return function is computed, it captures the current value of the parameter inc
.
This happens in the computation of this expression:
Compute adder(5)
Step 1: Bind parameter inc to 5
Step 2: Compute the value (x => x + inc) // This is when variable inc is captured
The key point is that when the value of the function is computed, inc
is bound to the value 5.
Later, when the value add5
is used, inc
is not bound anymore to any value - because the scope of its definition has been exited.
let add5 = adder(5);
console.log(add5(2)); // add5 uses the value of inc which was captured when the closure was created
// ==> 7
console.log(inc); // We confirm that inc is undefined
// ==> Uncaught ReferenceError: inc is not defined
Type of Closures
If we look back at the type of the closures returned when we compute adder(5)
- we obtain:
(x: number) => number
In words - it means that the value of adder(5)
is a function which receives a number x
and returns a number.
Note that the closure does not indicate that it depends on another variable (inc
) - because this is not part of the signature of the closure - it is an internal aspect of the closure.
Type Compatibility
As part of the type checking performed by the TypeScript compiler, one must determine whether two type expressions are compatible. This compatibility checking occurs in the following context for example:
- Assume we know the type of a variable
a
to be a type expression<type_a>
- We now compile the following expression:
let b : <type_b> = a; ...
At this point, the TypeScript compiler must determine whether
<type_a>
and<type_b>
are compatible. - Similarly, assume we know the type of a function
f
to be(x: <type_x>) => <type_y>
. - We now compile the following expression:
let z : <type_z> = v; f(z);
At this point, the TypeScript compiler must determine whether
<type_x>
(the type of the formal parameter of functionf
) and<type_z>
(the type of the actual variable passed as an argument to functionf
) are compatible.
Type Compatibility is NOT Symmetric: Subtyping
Type compatibility is not symmetric - T1
is compatible with T2
means we can substitute a value of type T2
with a value of type T1
and still obtain valid expressions. The reverse may not be true.
In general, think of type expressions as expressing constraints on the values that belong to the type.
When a type expression T1
expresses more constraints than T2
, then it means the type expression T1
denotes less values than T2
. It also means all the values in T1
meet the constraints specified by T2
.
To remember this - we say that T1
is a subtype of T2
- all the values in T1
satisfy the constraints of T2
.
When this is the case, it is safe to replace variables of type T2
with values or variables of type T1
- in short, it is safe to perform let t2 = t1
.
To remember the rule - remember: let t = s
is possible when s < t
- meaning we can bind with let
(or pass as a parameter) a value or variable of type s to a variable of type t when s is a subtype of t.
In this short form, s stands for source, t for target.
Compatibility Rules
We will revisit the rules of type compatibility in Chapter 3.
For now, consider the following practical aspects of type compatibility in TypeScript:
- Primitive type expressions are compatible when they are the same (that is,
number
is compatible withnumber
but not withboolean
and not withstring
). - Primitive type expressions are not compatible with any compound type expressions. For example
number
is not compatible withnumber[]
or with{ x:number }
. - Arrays are only compatible with arrays, maps with maps, functions with functions.
- Two array expressions
T1[]
andT2[]
are compatible whenT1
andT2
are compatible.
The rules for maps and functions are a bit more complex:
Map Type Compatibility: Structural Typing
Reference: https://www.typescriptlang.org/docs/handbook/type-compatibility.html
The basic rule of TypeScript for Map compatibility is:
x
is compatible with y
if y
has at least the same members as x
.
For example:
type Named = {
name: string;
}
let x: Named;
// y's inferred type is { name: string; location: string; }
let y = { name: "Alice", location: "Seattle" };
x = y;
The type expression inferred for y
is { name: string, location: string }
.
It has more constraints than Named
.
In TypeScript, these two types are compatible - that is the type of y
is a subtype of the Named
type.
To check whether y
is compatible with the type of x
, the compiler checks each property of x
to find a corresponding compatible property in y
. In this case, y
must have a member called name
that is a string
. It does, so the assignment is allowed.
The same rule for checking the compatibility of the assignment is used when checking function call arguments:
function greet(n: Named) {
console.log("Hello, " + n.name);
}
greet(y); // OK
Note that y
has an extra location
property, but this does not create an error.
Only members of the target type (Named
in this case) are considered when checking for compatibility.
This comparison process proceeds recursively, exploring the type of each member and sub-member.
This method of checking type compatibility is called structural typing. Structural typing is a way of relating types based solely on their members. This is in contrast with nominal typing which we know from Java and C++.
Consider the following code:
type Named = {
name: string;
}
type Person = {
name: string;
age: number;
}
Under structural typing, Person
is a subtype of Named
- even though this is not declared by the programmer; this subtyping relation is inferred by the compiler.
function greeter(n: Named): string {
return "Hello " + n.name;
}
let p: Person = { name: "John", age: 26 };
greeter(p); // OK because of structural typing: p declared as a subtype (Person) of Named
In nominally-typed languages like Java, the equivalent code would be an error because the Person type does not explicitly describe itself as being a subtype or an implementor of the Named type - the programmer does not define explicitly relations between the types.
Function Types Compatibility: Comparing the Types of Functions
(This is taken from https://www.typescriptlang.org/docs/handbook/type-compatibility.html):
While comparing primitive types and object types is relatively straightforward, the question of what kinds of functions should be considered compatible is a bit more involved. Let’s start with a basic example of two functions that differ only in their parameter lists:
let x = (a: number, t: string) => 0;
let y = (b: number, s: string) => 0;
y = x; // OK
Comparing Parameters Types
To check if x is assignable to y, we first look at the parameter list. Each parameter in x
must have a corresponding parameter in y
with a compatible type. Note that the names of the parameters are not considered, only their types. In this case, every parameter of x
has a corresponding parameter with identical type in y
, so the assignment is allowed.
We ignore here the complexity introduced by optional arguments and differing number of arguments - we will simplify by stating that two function parameter lists are compatible if they have the same length. The comparison of the type of each parameter, however, is surprising.
Comparing Return Types
Let’s look at how return types are treated, using two functions that differ only by their return type:
let x = () => ({ name: "Alice" });
let y = () => ({ name: "Alice", location: "Seattle" });
x = y; // OK
y = x; // Error because x() lacks a location property
The type system enforces that: the source function’s return type be a subtype of the target type’s return type. t = s s.r < t.r
That is - when checking f1: (x: T1) => U1
with f2: (x: T2) => U2
- f1
is a subtype of f2
(meaning f2 = f1
is ok) we verify that U1
is a subtype of U2
,
but we check the reverse relation for the parameters, that is, T2
is a subtype of T1
.
The behavior of parameters and return values for function types when considering subtypes is well explained (with examples) in this post: https://www.stephanboyer.com/post/132/what-are-covariance-and-contravariance.
In short, we say that function (as a type constructor) is contra-variant on parameters and covariant on return type.
Summary
Types
- Types are useful in programming languages:
- they allow the verification of type correctness at compile time instead of failing at runtime
- they are a reliable form of documentation of programs.
- Types in TypeScript are optional. The TypeScript compiler checks the types that are provided by the programmer and compiles the code to untyped JavaScript.
- TypeScript type annotations are added in the following places in TypeScript expressions:
let varName: <typeAnnotation>; // After variable declarations function fname(param : <typeAnnotation>, ...) : <typeAnnotation> { ... } // As part of a function signature
Type Language
- TypeScript provides a type language to write type annotations.
- Primitive type expressions are
number
,boolean
andstring
- Array type expressions are of the form
T[]
whereT
can be any type expression. - Map type expressions are of the form
{ key: T, ...}
whereT
can be any type expression. - Map type expressions can be given a name in the form:
type <name> = <map-type-expression>
- Type expressions can be embedded into each other to specify the type of complex values like JSON types.
- Types can be implicit and inferred by the TypeScript compiler in some cases.
- Recursive types such as trees can be defined using optional properties in named map types.
- Generic types can be defined using type variables in type expressions.
Function Types
- Function types specify the type of expected parameters and the return types. They are of the form
(x: T,...) => T
. This is called the function signature. - Closures may capture variable bindings - but these do not appear in their type - only the parameters and return values do.
Type Compatibility
- A source type expression \(T_2\) is compatible with a target type expression \(T_1\) when a variable declared with annotation \(T_1\) can be bound to a value or variable of type \(T_2\) (either through
let
or parameter passing to a function or assignment). This ensures that operations performed on the source value will be safe at runtime (for example, accessing a key in a map is an operation that is safe if the map types are compatible). - Primitive types are compatible when they are equal.
- Array types are compatible when the element types are compatible.
- Map types
x
andy
are compatible wheny
has at least the same members asx
. - Map types are checked structurally and not nominally in contrast to Java.
- Function types are compatible when:
- The names of the parameters are ignored in the comparison.
(x: T1) => U1
is compatible with(y: T2) => U2
ifT2 is compatible with T1
andU1 is compatible with U2
(note the reversing for parameters).