introduce

This is the 19th day of my participation in the August Wenwen Challenge.More challenges in August

Type compatibility in TypeScript is based on structural subtypes. A structural type is a way to describe a type using only its members. It just contrasts with the nominal type. Look at the following example:

interface Named {
    name: string;
}

class Person {
    name: string;
}

let p: Named;
// OK, because of structural typing
p = new Person();
Copy the code

In languages that use name-based types, such as C# or Java, this code will fail because the Person class does not explicitly say that it implements the Named interface.

TypeScript’s structural subtypes are designed based on how JavaScript code is typically written. Because anonymous objects, such as function expressions and object literals, are widely used in JavaScript, it is better to use the structural type system to describe these types than the nominal type system.

Note on reliability

TypeScript’s type system allows certain operations that are not secure at compile time. When a type system has this property, it is considered “unreliable”. TypeScript allows this kind of unreliable behavior to happen carefully. In this article, we will explain when this happens and the benefits.

start

The basic rule of TypeScript’s structured type system is that if X is compatible with Y, then Y has at least the same properties as X. Such as:

interface Named {
    name: string;
}

let x: Named;
// y's inferred type is { name: string; location: string; }
let y = { name: 'Alice', location: 'Seattle' };
x = y;
Copy the code

Here we check to see if y can be assigned to x, and the compiler checks each attribute in X to see if it can find the corresponding attribute in Y. In this case, y must contain a string member whose name is name. Y satisfies this condition, so the assignment is correct.

Use the same rules for checking function arguments:

function greet(n: Named) {
    console.log('Hello, ' + n.name);
}
greet(y); // OK
Copy the code

Note that y has an extra location attribute, but this does not raise an error. Only members of the target type (in this case Named) are checked for compatibility.

The comparison is performed recursively, checking each member and its children.

Compare two functions

Relatively easy to understand when comparing primitive types with object types, the problem is how to determine whether the two functions are compatible. Let’s start with two simple functions with slightly different argument lists:

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
x = y; // Error
Copy the code

To see if x can be assigned to Y, first look at their argument list. Each parameter of x must have a corresponding type of parameter in y. Note that it doesn’t matter if the parameters have the same name, just their type. Here, each argument to x has a corresponding argument in y, so assignment is allowed.

The second assignment error, because y has a required second argument, but x does not, so assignment is not allowed.

You might wonder why arguments are allowed to be ignored, as in the example y = x. The reason is that ignoring extra arguments is quite common in JavaScript. For example, Array#forEach gives the callback three arguments: the array element, the index, and the entire array. Nevertheless, it is useful to pass in a callback that takes only the first argument:

let items = [1, 2, 3];

// Don't force these extra arguments
items.forEach((item, index, array) => console.log(item));

// Should be OK!
items.forEach((item) => console.log(item));
Copy the code

Let’s see how to handle return value types by creating two functions that differ only in their return value types:

let x = () => ({name: 'Alice'});
let y = () => ({name: 'Alice', location: 'Seattle'});

x = y; // OK
y = x; // Error, because x() lacks a location property
Copy the code

The type system enforces the return value type of the source function to be a subtype of the return value type of the target function.

Function parameters are bidirectional covariant

When comparing function parameter types, the assignment succeeds only if the source function parameter can be assigned to the target function or vice versa. This is unstable because the caller may pass in a function with more precise type information, but call the function with less precise type information. In fact, this is extremely error-free and implements many of the common patterns found in JavaScript. Such as:

enum EventType { Mouse, Keyboard }

interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }
interface KeyEvent extends Event { keyCode: number }

function listenEvent(eventType: EventType, handler: (n: Event) => void) {
    /* ... */
}

// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + ',' + e.y));

// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) => console.log((e as MouseEvent).x + "," + (e as MouseEvent).y));
listenEvent(EventType.Mouse, ((e: MouseEvent) => console.log(e.x + "," + e.y)) as (e: Event) => void);

// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e));
Copy the code

You can use strictFunctionTypes compilation options to make TypeScript report errors in this case.

Optional and remaining parameters

When comparing function compatibility, optional and required arguments are interchangeable. It is not an error to have additional optional parameters on the source type, nor is it an error to have optional parameters of the target type that have no corresponding parameters in the source type.

When a function has a surplus argument, it is treated as an infinite number of optional arguments.

This is unstable for the type system, but optional arguments are generally not mandatory from a runtime point of view, since most functions are equivalent to passing some undefinded.

Here’s a good example of a common function that takes a callback function and calls it with parameters that are predictable to programmers but uncertain to the type system:

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)); // Confusing (x and y are actually required) and undiscoverable invokeLater([1, 2], (x? , y?) => console.log(x + ', ' + y));Copy the code

Function overloading

For overloaded functions, each overload of the source function finds the corresponding function signature on the target function. This ensures that the target function can be called from where the source function can be called.

The enumeration

Enumeration types are compatible with numeric types, and numeric types are compatible with enumeration types. Different enumeration types are incompatible. For instance,

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let status = Status.Ready;
status = Color.Green;  // Error
Copy the code

class

Classes are similar to object literals and interfaces, but with one difference: classes have types for static parts and instance parts. When objects of two class types are compared, only instance members are compared. Static members and constructors are outside the scope of the comparison.

class Animal {
    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
Copy the code

Private and protected members of the class

Private and protected members of a class affect compatibility. When checking the compatibility of class instances, if the target type contains a private member, the source type must contain that private member from the same class. Similarly, this rule applies to type checking that contains instances of protected members. This allows subclasses to assign to their parents, but not to other classes of the same type.

The generic

Because TypeScript is a structured type system, type parameters only affect the result types that use them as part of the type. For instance,

interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y;  // OK, because y matches structure of x
Copy the code

In the above code, x and y are compatible because their structures are not different when using type parameters. To see how this works, change the example and add a member:

interface NotEmpty<T> {
    data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

x = y;  // Error, because x and y are not compatible
Copy the code

Here, a generic type is used as if it were not a generic type.

For generic parameters that do not specify a generic type, all generic parameters are compared as any. The result type is then used for comparison, as in the first example above.

For instance,

let identity = function<T>(x: T): T {
    // ...
}

let reverse = function<U>(y: U): U {
    // ...
}

identity = reverse;  // OK, because (x: any) => any matches (y: any) => any
Copy the code

Advanced topics

Subtypes and assignments

So far, we have used “compatibility,” which is not defined in the language specification. In TypeScript, there are two types of compatibility: subtypes and assignments. They differ in that assignment extends subtype compatibility by adding rules that allow assignment back and forth to any, and back and forth between enums and corresponding numeric values.

Different parts of the language use each of these mechanisms. In fact, type compatibility is controlled by assignment compatibility, even in the implements and extends statements.