Not only does TypeScript include string, Number, Boolean, void, null, undefined, symbol, unknown, never, any, enum, etc. We can also customize types by interface and type. Some of these types can and cannot be assigned to each other, depending on TypeScript’s type-compatibility system. Today we’ll talk about TypeScript type-compatibility rules.

Compatibility of nominal types

A nominal type refers to: Compatibility or equivalence of data types is determined by explicit declarations or type names, common in Java, Kotlin, C#, and other language systems, The built-in TypeScript types string, number, Boolean, void, null, undefined, symbol, unknown, never, any, and enum handle type compatibility based on rules for nominal types. The specific rules are as follows:

any

  • Its value can be assigned to divideneverAny type other than;
  • Any type of value can be assignedany.

unknown

  • Its value can only be assigned to other typesany;
  • Any type of value can be assignedunknown.

never

  • The value can be assigned to any other type;
  • Only can beneverThe value of thenever.

void

  • Its value can only be assigned to other typesanyunknown;
  • Only other types can be addedany,never,null,undefinedThe value of the giftvoid.

null

  • Its value can only be assigned to other typesany,unknownundefined;
  • Only other types can be addedany,never,undefinedThe value of the giftnull.

undefined

  • Its value can only be assigned to other typesany,unknownnull;
  • Only other types can be addedany,never,nullThe value of the giftundefined.

enum

  • Enumerations are compatible with numeric types.
  • Enumeration variables from different enumerations are considered incompatible.

In addition to the special types mentioned above, the compatibility of basic types like String, number, Boolean, symbol, and so on is as follows:

  • Its value can only be assigned to other typesanyunknown;
  • In strict mode, only other types can be addedany,never,unknownIs assigned to this type.
  • In non-strict mode, only other types can be addedany,never,unknown,null,undefinedIs assigned to this type.

Compatibility of structure types

Besides compatible type in the name of the above rules, the type of the TypeScript compatibility is based on the structure type more compatible rule: if the two types of structure, they said they are compatible with each other, and can be assigned to each other (i.e. if y type x is compatible type, then the type y at least have the same properties and type x). Take the following example:

interface Named {
  name: string;
}

class Person {
  name: string;
}

let p: Named;
p = new Person();
Copy the code

In the code above, the Person class doesn’t explicitly declare that it implements the Named interface, but because the Person class has the same structure as the Named interface, they are compatible. This is because anonymous objects (such as anonymous functions and object literals) are widely used in JavaScript, and using structural types to describe types is more efficient than using nominal types.

Freshness characteristics

As mentioned above, two types that meet the structural type compatibility rules are compatible with each other. Are there exceptions? Let’s look at the following example:

interface Named {
  name: string;
}

interface Person {
  id: number;
  name: string;
}

let p: Named;
p = {
  id: 1.// The type "{id: number; name: string; } "assigned to type" Named ". Object literals can specify only known properties, and the "ID" is not in type "Named". ts(2322)
  name: 'Tom'};Copy the code

In the above code, although the literal given to the variable P is fully compliant with structural type compatibility rules, it throws an exception, mainly caused by the Freshness feature in TypeScript, which performs more rigorous type detection on object literals: An object literal can be assigned to a target variable only if the type of the target variable is exactly the same as the type of the object literal, otherwise a type error will be thrown. We can eliminate exceptions by:

let p: Named;
p = {
  id: 1.name: 'Tom',}as Person;
Copy the code

or

let p: Named;
let person: Person = {
  id: 1.name: 'Tom'}; p = person;Copy the code

Class compatibility

When determining whether two classes are compatible, in addition to following the above structure-type compatibility rules, note the following:

  • You just need to compare the properties and methods of a class instance for compatibility.
  • Private, protected properties and methods must come from the same class.

The following are specific examples for analysis:

class Animal {
  feet: number;
  constructor(name: string, feet: number){}}class Cat {
  feet: number;
  constructor(feet: number){}}let animal: Animal;
let cat: Cat;

animal = cat;
cat = animal;
Copy the code

In the code above, the Animal class and the Cat class share the property feet, and the two classes are compatible even if their constructors are different. Look at the following example:

class Animal {
  protected feet: number;
}

class Cat {
  protected feet: number;
}

let animal: Animal;
let cat: Cat;

animal = cat; // Cannot assign type "Cat" to type "Animal". The attribute "feet" is protected, but the type "Cat" is not a derived class from "Animal". ts(2322)
cat = animal; // Cannot assign type "Animal" to type "Cat". The attribute "feet" is protected, but the type "Animal" is not a derived class from "Cat". ts(2322)
Copy the code

In the code above, we defined the protected feet property in the Animal and Cat classes respectively, and now if variables of this type are assigned to each other, an exception will be thrown. We can eliminate such methods through class inheritance, as in the following example:

class Animal {
  protected feet: number;
}

class Cat extends Animal {}

let animal: Animal;
let cat: Cat;

animal = cat;
cat = animal;
Copy the code

Compatibility with generics

The compatibility of generics is affected only if its type parameter is used by a member. For example, in the following example, the type parameter T has no effect on compatibility:

interface Empty<T> {}

let x: Empty<number>;
let y: Empty<string>;

x = y; // ok
Copy the code

When the type parameter T is used by a member, it affects compatibility after the generic is instantiated:

interface Empty<T> {
  data: T;
}

let x: Empty<number>;
let y: Empty<string>;

x = y; // Cannot assign type "Empty
      
       " to type "Empty
       
        ". Type "string" cannot be assigned to type "number". ts(2322)
       
      
Copy the code

For non-explicitly specified input parameter generic compatibility, TypeScript instantiates the any type as all non-explicitly specified input parameter types, and then checks for compatibility:

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

Father and son type

Parent-child types are key to understanding TypeScript type compatibility. If A variable requires A value of type A, if we can assign A value of type B to the variable, then type B is A subtype of type A, and type A is the parent type of type B. Take the following example:

type StringOrNumber = string | number;

let value: StringOrNumber;
value = 123;
value = "12345678";

let arrayValue: Array<StringOrNumber> = [123];
value = arrayValue; // Cannot assign type "StringOrNumber[]" to type "StringOrNumber". Type "StringOrNumber[]" cannot be assigned to type "string". ts(2322)
Copy the code

In the above code, we define a variable value of type StringOrNumber. We can set the value to 123 or “12345678”, but when we assign an array to the variable, we throw an exception. This suggests that:

  • typenumberstringIs a type ofStringOrNumbersubtypesType,StringOrNumberIs a type ofnumberstringThe parent type.
  • typeArray<StringOrNumber>And typeStringOrNumberUnable to formParent-child type relation.

variant

If we can infer a parent-child type relationship between two types, and based on that relationship we can infer a parent-child type relationship between more complex types constructed from these two types, we call it a variant.

The meaning of variants is to ensure type safety and avoid unexpected problems during compilation or running of applications. According to the transformation rules of parent-child type relations, we can divide variants into covariant, contravariant, bidirectional covariant and invariant. Here we introduce one of them:

covariance

First, let’s look at the following example:

class Animal {
  run(){}}class Dog extends Animal {}let dogs: Dog[] = [new Dog()];
let animals: Animal[];

animals = dogs;
animals[0].run();
Copy the code

In the code above, we define the Animal class and the Dog class, and the parent-child type relationship is as follows:

  • DogAnimalSubtype of;
  • AnimalDogThe parent type.

Since the above code works, we can infer that the parent-child type relationship between type Animal[] and type Dog[] is:

  • Dog[]Animal[]Subtype of;
  • Animal[]Dog[]The parent type.

It can be seen that type Animal[] and type Dog[] retain the parent-child type relationship between type Animal and type Dog. For variations that retain the parent-child type relationship, we call them covariations.

inverter

Now that we understand the concept of covariation, let’s look at another example:

class Animal {
  run(){}}class Dog extends Animal {
  woof(){}}function train(dog: Dog) :void {
  dog.woof();
}

let animals: Animal[] = [new Dog()];
animals.forEach(train); // ts(2345)
Copy the code

In the code above, we define the Animal class and the Dog class, and the parent-child type relationship is as follows:

  • DogAnimalSubtype of;
  • AnimalDogThe parent type.

ForEach’s signature is: (arg: Animal) => void, train’s signature is: (arg: Dog) => void. According to the relationship between Dog and Animal, we can deduce:

  • (arg: Dog) => void(arg: Animal) => voidSubtype of;
  • (arg: Animal) => void(arg: Dog) => voidParent type of.

According to the parent-child compatibility rules, we could have assigned train to forEach but actually raised an exception. This is because train needs Dog. Animals cannot guarantee that every item is Dog. TypeScript inverts function parameter types: if type B is A subtype of type A, the parent-child type relationship between types A and B is reversed (type B becomes the parent type of type A) in the function’s parameters. For the above example, we can change it to:

function train(animal: Animal) :void {
  animal.run();
}

let dogs: Dog[] = [new Dog()];
dogs.forEach(train);
Copy the code

ForEach: (arg: Dog) => void, train: (arg: Animal) => void

  • (arg: Animal) => void(arg: Dog) => voidSubtype of;
  • (arg: Dog) => void(arg: Animal) => voidParent type of.

By comparing the parent-child type relationship between type Dog and type Animal itself and the parent-child type relationship in function parameters, there is an inversion between them. We call this inversion, and it is mainly applied to function parameter types.

Two-way covariance

If type A is A subtype of type B, the variant is called bidirectional covariant if type A is both A subtype of type B and A parent of type B (and vice versa). Take the following example:

interface BaseEvent {
  timestamp: number;
}

interface MyMouseEvent extends BaseEvent {
  x: number;
  y: number;
}

function addEventListener(handler: (n: BaseEvent) => void) {
}

addEventListener((e: MyMouseEvent) = >{});// ts(2345)
Copy the code

As you can see, only contravariant parameter types are type-safe, so in TypeScript’s strict mode, functions are contravariant parameter types. We can set TypeScript to non-strict mode (strictFunctionTypes or strict to false), at which point the parameter types become bidirectional covariant. However, since it is not type safe, it is not recommended. Generics can be used to ensure type safety, so the addEventListener in the appellate code can be changed to:

function addEventListener<E extends BaseEvent> (handler: (n: E) => void) {}Copy the code

The same

If type A is A subtype of type B, the variant is said to be invariant if type A and type B do not form A parent-child type relationship. Take the following example:

class Animal {
  run(){}}class Dog extends Animal {
  woof(){}}class Cat extends Animal {}let dogs: Dog[] = [new Dog()];
let animals: Animal[];

animals = dogs;

animals.push(new Animal());
dogs.forEach(dog= > dog.woof());
Copy the code

The code above compiles, but raises dog.woof is not a function at runtime. Let’s examine why:

  • According to the above description, analyzeanimals.push(new Animal())We know from the previous codeDog[]Animal[]Parent-child type relationforcovariance;
  • And then the arrayanimalsThere’s a new oneAnimalInstance, due toanimalsdogsIt points to the same array, so yeahanimalsThe operation directly affecteddogsAt this time,Dog[]Animal[]Parent-child type relationIs insecure and undeterminable in some languages (e.gkotlinIn TypeScript, this is not allowed, but TypeScript compiles but still throws an exception at runtime.

So in TypeScript, it’s important to be careful with mutable array compatibility to avoid unexpected runtime errors in order to avoid immutable problems.

Function compatibility

Two functions can be compatible and assigned to each other only if the following options are compatible:

The return type

The return type of a function is covariant, as in the following example:

interface Point2D {
  x: number;
  y: number;
}

interface Point3D {
  x: number;
  y: number;
  z: number;
}

let iMakePoint2D = (): Point2D= > ({ x: 0.y: 0 });
let iMakePoint3D = (): Point3D= > ({ x: 0.y: 0.z: 0 });

iMakePoint2D = iMakePoint3D; // OK
iMakePoint3D = iMakePoint2D; //ts(2322)
Copy the code

Since Point3D is a subtype of Point2D and can be assigned to iMakePoint2D from iMakePoint3D, IMakePoint2D cannot be assigned to iMakePoint3D, since () => Point3D is a subtype of () => Point2D, we can conclude that the return value type of the function is covariant.

The parameter types

The parameter type of the function is contravariant, and the derivation steps have been discussed in detail during the discussion of contravariant, which will not be repeated here.

Number of parameters

Function compatibility depends on the number of arguments, as shown in the following example:

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

y = x; // OK
x = y; // ts(2322)
Copy the code

In the above code, x can be assigned to y because the number of arguments to function x is less than y.

Optional and remaining parameters

Optional parameters are compatible with remaining and non-optional parameters, such as the following example:

let optional = (x? :number, y? :number) = > {};
let required = (x: number, y: number) = > {};
let rest = (. args:number[]) = > {};
required = optional; // ok
rest = optional; // ok
optional = rest; // ts(2322)
optional = required; // ts(2322)
rest = required; // ok
required = rest; // ok
Copy the code

In the above code, we cannot assign rest and required to optional because in strict mode we can only assign any, never, and unknown to number. If we want this to work, You can set the compilation option strictNullChecks to false.

conclusion

In this article, we discuss TypeScript type compatibility in detail. Nowadays, TypeScript plays an important role in the construction and maintenance of large applications. Mastering the TypeScript type system will help you:

  • In team collaboration, develop structured contract specifications for interfaces.
  • Common runtime errors, such as null value problems, are detected in advance to improve system security and stability.
  • This facilitates subsequent maintenance and expansion of applications.

Due to the limited cognition of personal knowledge, if there are omissions, mistakes, we hope to discuss together; Finally, thank you for your reading. ^ _ ^

Refer to the link

  • Basarat. Gitbook. IO/typescript /…
  • www.typescriptlang.org/docs/handbo…