TypeScript is a superset of JavaScript, with its most notable feature being the addition of type enforcement on top of the existing JavaScript language model, which is a static type system. This ensures that variables in the code are in a type-safe state, meaning they can only be assigned values of the same type, and objects can only access their own properties and methods.
Type Safety & Type Transformation#
Type safety means that different places within the same segment are required to be interpreted using the same method, allowing developers to catch hidden errors early during the compilation phase.
let num: number = 1;
let bool: boolean = num; // Error: Type 'number' is not assignable to type 'boolean'.
In the above case, the number
type and the boolean
type are completely different, and they do not form an inheritance relationship, so they cannot be assigned to each other.
So, when we need to convert a number stored in a `number`
to a boolean
variable, how do we change the type?
let num: number = 1;
let bool: boolean = Boolean(num);
Clearly, since the Boolean
constructor has the capability to convert number
to boolean
, we just need to pass the number as its argument for conversion. TypeScript does not consider this a type error, and you can see the definition of this conversion in TypeScript's built-in type information.
// typescript/lib/lib.es5.d.ts
interface BooleanConstructor {
new (value?: any): Boolean;
<T>(value?: T): boolean;
readonly prototype: Boolean;
}
From this, we can conclude:
- In strongly typed languages, if two types do not have an inheritance relationship, they cannot be directly assigned to each other.
- Languages that cannot be assigned to each other can perform type conversion through defined methods, known as type transformation.
So, when we encounter the use of integrated types, how do we handle the conversion between subclasses and superclasses?
At this point, we will use the following two type transformation methods:
- Covariant: Covariance indicates that
Comp<T>
type is compatible withT
. - Contravariant: Contravariance indicates that
Comp<T>
type is compatible with the opposite ofT
. - Bivariant: Bivariance indicates that
Comp<T>
type is bi-directionally compatible withT
. - Invariant: Invariance indicates that
Comp<T>
type is not compatible withT
in either direction.
Clearly, the example of number
and string
mentioned above is a manifestation of invariance.
Type Inheritance and Polymorphism (Reference)#
Type inheritance and polymorphism are important parts of the foundational theory of computer science. Due to their theoretical nature and complexity, they will not be elaborated on here.
Subtype#
In programming language theory, a subtype is a form of type polymorphism. In this form, a subtype can replace another related data type (supertype). In other words, subprograms, functions, and other program elements that operate on supertype elements can also operate on the corresponding subtype. If S is a subtype of T, this subtype relationship is usually written as
S <: T
, meaning that in any environment where a T type object is required, an S type object can be safely used. The precise semantics of subtypes depend on the meaning of "Y can be safely used in environment X" in the specific programming language. The type system of programming languages defines their different subtype relationships.
Reference: https://en.wikipedia.org/wiki/Subtyping
Polymorphism#
In programming languages and type theory, polymorphism refers to providing a unified interface for entities of different data types or using a single symbol to represent multiple different types.
Reference: https://en.wikipedia.org/wiki/Polymorphism_(computer_science)
Covariance#
Covariance is easy to understand; a subtype inherits from a supertype, so the information contained in the subtype must be equal to or greater than that of the supertype, meaning the information required by the supertype must be included in the subtype.
For example, we have the following two Interfaces:
export interface Animal {
age: number;
bornAt: Date;
}
export interface Dog extends Animal {
name: string;
}
In this instance, we define two types, Animal
and Dog
, where Dog
inherits from Animal
. Since Dog
inherits all the information from Animal
, the actual type of Dog
is equivalent to this:
export interface Dog {
name: string;
age: number; // extends Animal
bornAt: Date; // extends Animal
}
Covariance is easy to understand; if a subtype cannot be assigned to a supertype, it indicates that there is a problem with the family relationship or the type system.
const dog: Dog = {
name: "AHdark",
age: 3,
bornAt: new Date("2007-04-28 18:00:00"),
};
const animal: Animal = dog; // OK
console.log(animal); // { age: 3, bornAt: 2007-04-28T18:00:00.000Z }
As shown in the above code, when we assign a subtype to a supertype, the following occurs:
- The properties inherited from the supertype remain after assignment.
- The properties unique to the subtype do not remain after assignment.
Thus, type transformation is essential for implementing parent-child relationships, as it increases the flexibility of the type system while ensuring type safety.
Contravariance#
Contravariance refers to the conversion from a supertype to a subtype. We continue using Animal
and Dog
as an example:
export interface Animal {
age: number;
bornAt: Date;
}
export interface Dog extends Animal {
name: string;
}
const printAnimal = (animal: Animal) => {
console.log(animal.age);
console.log(animal.bornAt);
};
const printDog = (dog: Dog) => {
printAnimal(dog);
console.log(dog.name);
};
const dog: Dog = { age: 1, bornAt: new Date(), name: "Fido" };
const animal: Animal = { age: 1, bornAt: new Date() };
printDog(dog); // works
printAnimal(dog); // works
printAnimal(animal); // works
printDog(animal); // error. argument of type 'Animal' is not assignable to parameter of type 'Dog'.
As we can see, we performed four operations involving types:
- Outputting
Dog
throughprintDog()
, successful. - Outputting
Dog
throughprintAnimal()
, successful. - Outputting
Animal
throughprintAnimal()
, successful. - Outputting
Dog
throughprintDog()
, resulting in a type error.
You might feel a bit confused here. We know that Dog
is a subtype of Animal
, meaning that whatever Animal
has, Dog
must have, but not necessarily the other way around.
In the printDog
function, if we pass in an Animal
, when accessing object.name
, it will throw an error because Animal
does not possess this property. Therefore, in the type system, we must prohibit this unprocessed covariance. Only after we process the incoming data can it be considered covariance.
interface ConvertAnimalToDog {
(animal: Animal): Dog;
}
const convertAnimalToDog: ConvertAnimalToDog = (animal) => ({
...animal,
name: "Any animal",
});
const printAnimalAsDog = (animal: Animal) => {
const dog: Dog = convertAnimalToDog(animal);
printDog(dog);
};
printAnimalAsDog(animal);
For instance, in this case, we complete the name
for Animal
, giving it the properties that Dog
has, thus allowing it to be converted to Dog
and subsequently used in printDog()
. This process is referred to as contravariance.
Bivariance#
As mentioned in the previous section on contravariance, the current TypeScript does not allow the direct assignment of a supertype to a subtype, which is a reverse covariance operation. However, before TS 2.x, such assignments were supported, meaning that a supertype could be assigned to a subtype, and a subtype could be assigned to a supertype, which is both contravariant and covariant, called "bivariance."
This is clearly problematic, as it cannot guarantee type safety. Therefore, later versions of TypeScript added a compilation option strictFunctionTypes
, which, when set to true
, only supports contravariance of function parameters, and when set to false
, allows bivariance.
Conclusion#
In this article, we briefly introduced the specific manifestations of covariance, contravariance, bivariance, and invariance in TypeScript's type transformations, to help everyone better understand why TypeScript is a superset of JavaScript.
Clearly, its main achievement lies in transforming a weakly typed language into a statically strongly typed language. In actual development, strongly typed languages help shift errors from runtime to compile time, thereby reducing the time consumed in the debugging process.
As a staunch advocate of strongly typed languages, I recommend using TypeScript in front-end projects and Node.js projects, especially in open-source projects, to achieve higher maintainability.
In the future, I will write another article detailing some of the features of TypeScript in type transformations.