1. Type Inference
Assignment Inference
Type inference mechanism reduces the syntactic burden brought by strong typing, for example:
let x = 3;
// Equivalent to
let x: number = 3;
Compiler can infer variable type is number based on variable initial value 3, therefore in most scenarios no need to explicitly declare type, it can guess
P.S. Even in [Haskell](/articles/类型-haskell 笔记 3/) where everything needs type determined in advance, not everywhere is filled with type declarations, but quite concise, precisely because compiler provides powerful type inference support
In assignment-like scenarios can determine type based on target value, specifically:
-
Variable or (class) member initial value
-
Parameter default value
-
Function return value
These 3 types of values can all provide direct type information, thereby determining target type. Besides these, there are some less direct scenarios, such as array types
Array Types
let x = [0, 1, null];
Elements in array are either number or null, and number "compatible" with null, therefore infer x type is number[]
Null, Undefined and Never are subtypes of other types, therefore can be assigned to any other type variables
(From [Basic Types_TypeScript Notes 2](/articles/基本类型-typescript 笔记 2/#articleHeader3))
That is to say, to determine array type, first need to determine each element's type, then consider their compatibility relationships, finally determine a most "wide" type (accommodates all other types in array, called best common type) as array type
If none of array elements can accommodate all other types (i.e., cannot find best common type), use union type, for example:
// Infer mixin: (string | number | boolean)[]
let mixin = [1, '2', true];
class Animal {}
class Elephant extends Animal {}
class Snake extends Animal {}
// Infer zoo: (Elephant | Snake)[]
let zoo: Animal[] = [new Elephant(), new Snake()];
Contextual Inference
Compared with assignment inference, contextual inference is another different approach:
Inference
Value ------> Variable Type
Search Match (Infer)
Context -----> Context Type -----------> Variable Type
Former from value to type, latter from type to type. Derive type information from context, then map to variables by position, for example:
// Infer mouseEvent: MouseEvent
window.onmousedown = function(mouseEvent) {
// ...
};
Right side anonymous function as mousedown event handler, conforms to DOM API type constraints, thereby deriving parameter type:
interface MouseEvent extends UIEvent {
readonly clientX: number;
readonly clientY: number;
//...many other properties
}
interface GlobalEventHandlers {
onmousedown: ((this: GlobalEventHandlers, ev: MouseEvent) => any) | null;
}
interface Window extends GlobalEventHandlers {/*...*/}
declare var window: Window;
(From TypeScript/lib/lib.dom.d.ts)
If脱离了 mousedown event handler this context, cannot infer parameter type:
// Infer mouseEvent: any
function handler(mouseEvent) {
console.log(mouseEvent.clickTime);
}
Many scenarios will infer types based on context, for example:
-
Parameters in function calls
-
Right side of assignment statements
-
Type assertions
-
Object members and array literals
-
returnstatements
2. Subtype Compatibility
In [TypeScript's 13 Basic Types](/articles/基本类型-typescript 笔记 2/), type hierarchy relationships are as follows:
[caption id="attachment_1899" align="alignnone" width="625"]
TypeScript Type Relationships[/caption]
Simple summary:
-
Any is most "wide". Compatible with all other types (in other words, all other types are Any's subtypes)
-
Never is most "narrow". Not compatible with any other types
-
Void compatible with Undefined and Null
-
All other types compatible with Never and Void
P.S. Compatibility can simply understand as whether assignable (end of article has rigorous description), for example:
let x: any;
let y: number;
let z: null;
// Any compatible with Number
x = y;
// Number compatible with Null
y = z;
// Null not compatible with Number
// Error Type 'number' is not assignable to type 'null'.
z = y;
Not only basic types have hierarchy, functions, classes, generics and other complex types also have such compatibility relationships
3. Functions
Compatibility Determination
For type system, need to accurately determine whether one function type is compatible with another function type, for example in assignment scenario:
let log: (msg: string) => void
let writeToFile: (msg: any, encode: string) => void
// Types compatible? Should it error
log = writeToFile;
writeToFile = log;
From type safety perspective, replacing log with writeToFile is unsafe (missing encode parameter, writeToFile may not work properly), conversely is safe, because return value types are same, parameters are more than enough, msg type is also compatible (string is any's subtype)
Specifically, TypeScript type system's compatibility determination rules for function types are as follows:
-
Parameters: Require corresponding parameter types compatible, quantity allows excess
let x = (a: number) => 0; let y = (b: number, s: string) => 0; y = x; // OK x = y; // Error -
Return values: Require return value types compatible
let x = () => ({name: "Alice"}); let y = () => ({name: "Alice", location: "Seattle"}); x = y; // OK y = x; // Error, because x() lacks a location property -
Function types: Require satisfying bivariance constraints
Function Type Bivariance
Bivariance means simultaneously satisfying covariance and contravariance, simply put:
-
Covariance: Allows places where parent type appears, also allows subtypes to appear, i.e., Liskov Substitution Principle
-
Contravariance: Covariance reversed, i.e., allows places where subtype appears, also allows parent types to appear
-
Bivariance: Simultaneously satisfies covariance and contravariance
-
Invariance (or nonvariant): Neither satisfies covariance nor contravariance
Covariance is easy to understand, subtypes compatible with parent types, additionally have some (parent types don't have) extended properties or methods, therefore after replacing parent type with subtype, can still work normally (type safe)
While contravariance is not very intuitive, in what scenarios, after replacing subtype with parent type, can still guarantee type safety?
Member function overriding in inheritance relationships, is a typical example of contravariance:
class Example {
foo(maybe: number | undefined) { }
str(str: string) { }
compare(ex: Example) { }
}
class Override extends Example {
foo(maybe: number) { } // Bad: should have error.
str(str: 'override') { } // Bad: should have error.
compare(ex: Override) { } // Bad: should have error.
}
(From Overridden method parameters are not checked for parameter contravariance)
Comparing function types before and after overriding:
// foo
(maybe: number | undefined) => any
(maybe: number) => any
// str
(str: string) => any
(str: 'override') => any
// compare
(ex: Example) => any
(ex: Override) => any
P.S. str(str: 'override') is "narrower" than str(str: string) by an undefined, default value makes parameter value set lack undefined
Parameters all changed from "wide" types to narrower types, i.e., from parent types to subtypes, obviously, doing this is unsafe, for example:
function callFoo(example: Example) {
return example.foo(undefined);
}
callFoo(new Example()); // No problem
callFoo(new Override()); // May error, because subclass's foo doesn't accept undefined
Conversely, if subclass's overridden parameter types are "wider", then it's safe, for example:
class Example {
foo(maybe: number | undefined) { }
}
class Override extends Example {
foo(maybe: number) { } // Sound
}
This is the so-called contravariance, for member function parameters, replacing subtype with parent type is safe, i.e.:
Allows places where subtype appears, also allows parent types to appear
From type perspective, subtypes allow hierarchical (inheritance) relationships between types, from broad types to specific types, and covariance, contravariance and other relationships are built on this type hierarchy:
-
Covariance: Simple type hierarchy relationships preserved to complex types, this complex type is covariant, for example
AnimalisCat's parent type, and arrayAnimal[]is alsoCat[]'s parent type, so array types are covariant -
Contravariance: Simple type hierarchy relationships reversed in complex types, this complex type is contravariant. For example function type
Animal => stringisCat => string's subtype (because latter accepts narrower parameters), while simple typeAnimalisCat's parent type, then function type is contravariant
P.S. As we can see, contravariance is not intuitive, therefore to keep type system simple, some languages also consider type constructors invariant, although this may violate type safety
Specially, function types in TypeScript are bivariant, for example:
interface Comparer<T> {
compare(a: T, b: T): number;
}
declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;
animalComparer = dogComparer; // Ok because of bivariance
dogComparer = animalComparer; // Ok
(From Strict function types)
Theoretically should require function parameters contravariant, to ensure type safety, therefore:
// Assigning parent type to subtype, in contravariant scenarios is safe
dogComparer = animalComparer; // Ok
// Assigning subtype to parent type, in contravariant scenarios (function types) is unsafe
animalComparer = dogComparer; // Ok because of bivariance
Latter is unsafe, but very common in JavaScript world:
This is unsound because a caller might end up being given a function that takes a more specialized type, but invokes the function with a less specialized type. In practice, this sort of error is rare, and allowing this enables many common JavaScript patterns.
So TypeScript doesn't forcibly constrain function types contravariant, but allows bivariance. Further, when comparing two function types, as long as one side's parameters are compatible with other side's parameters, it's ok, as in above example dogComparer and animalComparer can be mutually assigned
Optional Parameters and Rest Parameters
When comparing parameter compatibility, don't require matching optional parameters, for example original type having extra optional parameters is legal, target type lacking corresponding optional parameters is also legal
For rest parameters, treat as infinite number of optional parameters, also don't require strict matching. Although from type system perspective unsafe, but in practical applications is a quite common "pattern", for example calling callback function with uncertain parameters:
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* ... Invoke callback with 'args' ... */
}
// Unsound - invokeLater "might" provide any number of arguments
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));
Function Overloads
For functions with multiple overloads, require each overload version of source function has corresponding version on target function, to guarantee target function can be called wherever source function is callable, for example:
interface sum {
(a: number, b: number): number;
(a: number[]): number;
}
// Two versions required by Sum
function add(a: any, b: any);
function add(a: any[], b?: any): any;
// Extra version
function add(a: any[], b: any, c: number): any;
function add(a, b) { return a; }
let sum: sum = add;
sum function has two overload versions, so target function must at least be compatible with these two versions
4. Enums
First, enum values from different enum types are not compatible, for example:
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };
let s = Status.Ready;
// Type 'Color.Green' is not assignable to type 'Status'.
s = Color.Green; // Error
Specially, numeric enums and numeric types are mutually compatible, for example:
enum Status { Ready, Waiting };
// Numeric compatible with enum value
let ready: number = Status.Ready;
// Enum value compatible with numeric
let waiting: Status = 1;
But string enums are not mutually compatible with string types
enum Status { Ready = '1', Waiting = '0' };
let ready: string = Status.Ready;
// Error Type '"0"' is not assignable to type 'Status'.
let waiting: Status = '0';
P.S. Although from actual type perspective, above assignment is legal, but in type system considers二者 incompatible, therefore errors
5. Classes
Classes are similar to object literal types and interfaces, difference is, classes simultaneously have instance types and static types, and when comparing two class instances, only compare instance members
Therefore, static members and constructors don't affect compatibility:
class Animal {
static id: string = 'Kitty';
feet: number;
constructor(name: string, numFeet: number) { }
}
class Size {
feet: number;
constructor(numFeet: number) { }
}
let a: Animal;
let s: Size;
a = s; // OK
s = a; // OK
Private and Protected Members
Member modifiers private and protected also affect type compatibility, specifically, require these members originate from same class, thereby guaranteeing parent class compatible with subclass:
class Animal {
private feet: number;
constructor() { }
}
class Cat extends Animal { }
// Tree looks exactly same as Animal
class Tree {
private feet: number;
constructor() { }
}
// Correct Parent class compatible with subclass
let animal: Animal = new Cat();
// Error Type 'Tree' is not assignable to type 'Animal'.
animal = new Tree();
// Correct Both "shapes" exactly same
let kitty: Cat = new Animal();
Assigning Tree instance to Animal type will error, although both look exactly same, but private property feet originates from different classes:
Types have separate declarations of a private property 'feet'.
Similarly, above example assigning Animal instance to Cat type doesn't error, because both member lists are same, and private property feet also originates from same Animal class
6. Generics
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // OK, because y matches structure of x
y = x; // OK, because x matches structure of y
Although Empty<number> and Empty<string> differ greatly, but generic definition doesn't use type parameter T (similar to unused variable, not much meaning), therefore mutually compatible
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
// Error Type 'Empty<string>' is not assignable to type 'Empty<number>'.
x = y;
At this time, generics with specified type parameters compare strictly like general specific types, for generics without specified type parameters, treat type parameter as any, then compare, for example:
let identity = function<T>(x: T): T {
//...
return x;
}
let reverse = function<U>(y: U): U {
//...
return y;
}
// Correct Equivalent to assigning (y: any) => any to (x: any) => any
identity = reverse;
7. Type Compatibility
Actually, TypeScript specification only defines 2 types of compatibility, subtype compatibility and assignment compatibility,二者 have subtle differences:
Assignment extends subtype compatibility with rules to allow assignment to and from any, and to and from enum with corresponding numeric values.
Assignment compatibility extends subtype compatibility, allows any mutual assignment, and enum and corresponding numeric values mutual assignment
As for type compatibility, specification doesn't define this concept, in most contexts, so-called type compatibility follows assignment compatibility, implements and extends clauses are no exception
No comments yet. Be the first to share your thoughts.