TypeScript 类型兼容——逆变、协变、双向协变和不变在 TypeScript 中,类型系统支持“逆变(Contr
在 TypeScript 中,类型系统支持“逆变(Contravariance)”、“协变(Covariance)”、“双向协变(Bivariance)”和“不变(Invariance)”的概念,这些概念主要用于理解类型之间的兼容性,尤其是在函数参数和返回值之间的关系。
类型安全和型变
首先,TypeScript 通过在 JavaScript 上添加静态类型系统来实现类型安全。类型安全意味着我们可以在编译时检测到可能的类型错误,防止它们在运行时导致程序崩溃。例如,TypeScript 不允许将一个 number 类型的值赋给一个 boolean 类型的变量,也不允许调用某个对象上不存在的方法。
let isDone: boolean = true;
isDone = 1; // Error: Type '1' is not assignable to type 'boolean'.
let currentDate: Date = new Date();
currentDate.exec(); // Error: Property 'exec' does not exist on type 'Date'.
在这两个例子中,TypeScript 的类型检查机制会在编译时报告错误,从而保证了类型的正确性和代码的可靠性。
然而,完全严格的类型安全有时会导致不便。例如,当我们处理子类型和父类型时,TypeScript 允许一些 变通
,以便在保证类型安全的前提下提供更大的灵活性。这种 变通
被称为型变(Variance)。
在类型系统中,当类型 A 和类型 B 存在继承关系时,使用 A 的地方是否也能使用 B。型变在函数的参数和返回值中表现尤为明显。型变通常包括以下四种类型:
-
协变(Covariance):如果 A 是 B 的子类型,那么
F<A>
也是F<B>
的子类型。换句话说,F<A>
可以赋值给F<B>
。协变通常用于函数的返回值类型。 -
逆变(Contravariance):如果 A 是 B 的子类型,那么
F<B>
是F<A>
的子类型。换句话说,F<B>
可以赋值给F<A>
。逆变通常用于函数的参数类型。
双向协变(Bivariance):TypeScript 中的一种特殊情况,允许函数参数类型既可以协变也可以逆变,虽然这种情况理论上不安全,但它是为了保持实际代码的兼容性。
不变(Invariance):F<A>
既不是 F<B>
的子类型,也不是其超类型。换句话说,F<A>
和 F<B>
是完全不兼容的类型。
协变
在 TypeScript 中,协变 主要是指在处理泛型、函数类型、数组等结构时,允许子类型被视为其父类型。简言之,协变允许子类型可以赋值给父类型,这是最常见的类型兼容性规则。
泛型默认是协变的,这就以为着如果 B 是 A 的子类型,那么 T<B>
也是 T<A>
的子类型。
例如,假设我们有一个泛型接口 Box<T>
:
class Animal {}
class Dog extends Animal {}
interface Box<T> {
value: T;
}
let animalBox: Box<Animal>;
let dogBox: Box<Dog> = { value: new Dog() };
animalBox = dogBox; // 合法,协变
他们相互赋值是不会错的,虽然这俩类型不一样,但是依然是类型安全的。
在这个例子中,Box<Dog>
可以赋值给 Box<Animal>
,因为 Dog 是 Animal 的子类型。这种关系在 TypeScript 的类型系统中是允许的,因为它符合协变的规则。
除了在类中,函数和数组中也都是可以协变的:
class Animal {}
class Dog extends Animal {}
type AnimalFactory = () => Animal;
type DogFactory = () => Dog;
let createAnimal: AnimalFactory;
let createDog: DogFactory = () => new Dog();
createAnimal = createDog; // 合法,协变
class Animal {}
class Dog extends Animal {}
let dogs: Dog[] = [new Dog(), new Dog()];
let animals: Animal[];
animals = dogs; // 合法,协变
协变在 TypeScript 中非常重要,因为它允许类型系统具有更大的灵活性。协变允许你在不违反类型安全的情况下,灵活地使用继承结构中的子类型和父类型。
在使用泛型数据结构时,协变允许我们在不影响类型安全的情况下,处理更广泛的类型。例如,List<Dog>
可以在 List<Animal>
的上下文中使用,因为 Dog 是 Animal 的子类型。协变使得函数可以返回更具体的类型而不影响函数的兼容性,这有助于设计灵活且类型安全的接口。例如,你可以在接口的实现中返回一个更具体的类型,而不需要更改接口本身的签名。
虽然协变提供了很多灵活性,但是在某些情况下也可能会带来问题,例如数组的协变可能会导致运行时错误,例如在使用 push 操作的时候,它可能会破坏数组的类型一致性,从而导致运行时报错:
class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}
let animals: Animal[] = [new Dog()];
animals.push(new Cat()); // 合法,但可能不是预期的行为
在这个例子中,animals 原本是 Dog[] 类型,但由于数组的协变,Cat 实例被推入了这个数组,这可能会导致一些不一致的问题。
逆变
在 TypeScript 中,逆变 是指,如果类型 A 是类型 B 的父类型(A <: B),那么在函数参数的位置上,类型 B 可以赋值给类型 A。换句话说,逆变允许父类型的函数参数赋值给子类型的函数参数。
在函数参数中的逆变,我们可以考虑以下类继承结构:
class Animal {
speak() {
console.log("Animal sound");
}
}
class Dog extends Animal {
speak() {
console.log("Bark");
}
}
假设我们有两个函数类型,一个接受 Animal 作为参数,另一个接受 Dog 作为参数:
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;
在逆变的情况下,AnimalHandler 可以赋值给 DogHandler。这是因为 Dog 是 Animal 的子类型,因此处理 Animal 的函数也可以处理 Dog。
let handleAnimal: AnimalHandler = (animal: Animal) => {
animal.speak();
};
let handleDog: DogHandler;
handleDog = handleAnimal; // 合法,逆变
在这个例子中,handleAnimal 可以处理任何 Animal,包括 Dog。因此,我们可以将 handleAnimal 赋值给 handleDog,这在类型系统中是安全的。
逆变的直观理解是,你可以将处理较宽泛类型的函数赋值给处理更具体类型的函数。如果一个函数能够处理 Animal,那么它肯定能够处理 Dog,因为 Dog 是 Animal 的一种特殊化形式。
在 TypeScript 中,型变通常用于描述类型在不同上下文中的传递方式。逆变与协变是相对的:
-
协变:子类型可以赋值给父类型。例如,Dog 是 Animal 的子类型,那么 Dog 类型的值可以赋值给 Animal 类型的变量。
-
逆变:父类型可以赋值给子类型。例如,Animal 是 Dog 的父类型,那么接受 Animal 参数的函数可以赋值给接受 Dog 参数的函数。
在 React 中,事件处理函数是逆变的一种典型应用。当你在一个通用的事件处理器中使用更广泛的事件类型时,逆变可以确保你的事件处理器适用于子类型事件。
例如,考虑一个处理 MouseEvent 的事件处理器:
type MouseEventHandler = (event: React.MouseEvent<HTMLButtonElement>) => void;
如果我们有一个更通用的事件处理函数,它可以处理任意的 DOM 事件,那么它可以被安全地用作处理特定的 MouseEvent:
type AnyEventHandler = (event: React.SyntheticEvent) => void;
const handleEvent: AnyEventHandler = (event) => {
console.log(event);
};
const handleMouseEvent: MouseEventHandler = handleEvent; // 合法,逆变
在这个例子中,handleEvent 可以处理任何 SyntheticEvent,其中包括 MouseEvent,所以它可以被赋值给 handleMouseEvent。这就是逆变的一个实际应用:更广泛的处理函数可以用于处理更具体的事件类型。
在使用高阶组件时,逆变也可能发挥作用。高阶组件(HOC)是接受一个组件并返回一个新组件的函数。在处理高阶组件时,如果传递给 HOC 的组件的属性是某种类型的父类型,那么 HOC 返回的组件可以安全地接受更具体的子类型属性。
假设你有一个高阶组件 withLogging,它可以将日志功能添加到任何组件中:
function withLogging<P>(Component: React.ComponentType<P>): React.FC<P> {
return (props: P) => {
console.log("Rendering", Component.name);
return <Component {...props} />;
};
}
如果你有一个组件 DogComponent 接受 Dog 类型的属性,那么你可以将 withLogging 应用于 DogComponent,并返回一个同样接受 Dog 属性的组件:
interface Dog {
name: string;
breed: string;
}
const DogComponent: React.FC<Dog> = ({ name, breed }) => (
<div>
{name} is a {breed}
</div>
);
const LoggedDogComponent = withLogging(DogComponent);
// 使用 LoggedDogComponent
<LoggedDogComponent name="Rex" breed="Labrador" />;
在这里,withLogging 的参数类型是泛型 P,而 Dog 是 P 的一个具体实例。由于 TypeScript 支持逆变,因此即使 P 是父类型,它也可以接受子类型 Dog 的参数,这使得 LoggedDogComponent 可以安全地使用 Dog 类型的属性。
逆变 是 TypeScript 中的一种型变,允许父类型的函数参数赋值给子类型的函数参数。它保证了类型安全,因为处理父类型的函数可以适用于子类型,而不会引发类型错误。这通常用于函数参数类型的兼容性处理,确保函数的灵活性和扩展性。
双向协变
在类型系统中,协变允许子类型赋值给父类型,逆变则允许父类型赋值给子类型。双向协变是 TypeScript 中的一种特殊行为,它允许函数参数的类型既可以协变,也可以逆变。
简单来说,双向协变允许你在处理函数参数类型时,既可以将子类型赋值给父类型,也可以将父类型赋值给子类型。
假设我们有两个类 Animal 和 Dog,Dog 继承自 Animal:
class Animal {
speak() {
console.log("Animal sound");
}
}
class Dog extends Animal {
speak() {
console.log("Bark");
}
}
现在我们定义两个函数类型,一个接受 Dog 作为参数,另一个接受 Animal 作为参数:
type DogHandler = (dog: Dog) => void;
type AnimalHandler = (animal: Animal) => void;
根据双向协变的规则,TypeScript 允许我们将 DogHandler 赋值给 AnimalHandler,也允许我们将 AnimalHandler 赋值给 DogHandler:
let handleDog: DogHandler = (dog: Dog) => {
dog.speak();
};
let handleAnimal: AnimalHandler = (animal: Animal) => {
animal.speak();
};
handleAnimal = handleDog; // 合法,协变
handleDog = handleAnimal; // 合法,逆变
在这个例子中,handleAnimal 可以处理 Animal 类型的参数,而 handleDog 只处理 Dog 类型的参数。尽管从理论上来说,将 handleAnimal 赋值给 handleDog 可能存在类型安全问题(handleAnimal 可能接受 Cat,而 handleDog 只能处理 Dog),但 TypeScript 允许这种赋值操作。
考虑一个更复杂的例子,其中我们有一个函数接受另一个函数作为参数:
function processAnimal(handler: (a: Animal) => void): void {
const animal = new Animal();
handler(animal);
}
function processDog(handler: (d: Dog) => void): void {
const dog = new Dog();
handler(dog);
}
根据双向协变的规则,processDog 可以接受 processAnimal 中的函数作为参数,反之亦然:
processDog((dog: Dog) => {
console.log(dog.speak());
}); // 合法
processAnimal((animal: Animal) => {
console.log(animal.speak());
}); // 合法
在这里,尽管 processAnimal 接受的是 Animal 类型的参数,而 processDog 只处理 Dog 类型,但 TypeScript 允许这种相互赋值。
虽然双向协变使得代码更灵活,但它也可能引发类型安全问题。特别是在处理继承关系较为复杂的情况下,可能会导致运行时错误。
function handleAnyAnimal(animal: Animal): void {
console.log("Handling an animal");
}
function handleOnlyDog(dog: Dog): void {
console.log("Handling a dog");
}
let dogHandler: (d: Dog) => void = handleAnyAnimal; // 合法,但可能不安全
dogHandler(new Dog()); // 正常
dogHandler(new Animal()); // 运行时错误,因为 Animal 不是 Dog
在这个例子中,将 handleAnyAnimal 赋值给 dogHandler 是合法的,因为 TypeScript 允许这种双向协变。然而,当我们尝试传递一个 Animal(而不是 Dog)给 dogHandler 时,可能会引发运行时错误。
不变
在类型系统中,不变 是指某个类型不能在子类型和父类型之间相互替换。具体来说,如果你有一个泛型类型 T<A>
和 T<B>
,即使 A 是 B 的子类型,T<A>
也不能赋值给 T<B>
,反之亦然。这种严格的类型匹配规则被称为不变。
考虑一个简单的泛型类:
class Animal {
name: string;
}
class Dog extends Animal {
breed: string;
}
class Cat extends Animal {
color: string;
}
interface Box<T> {
content: T;
}
let animalBox: Box<Animal> = { content: new Animal() };
let dogBox: Box<Dog> = { content: new Dog() };
let catBox: Box<Cat> = { content: new Cat() };
在不变的规则下,尽管 Dog 是 Animal 的子类型,但 Box<Dog>
不能赋值给 Box<Animal>
,同样 Box<Animal>
也不能赋值给 Box<Dog>
:
animalBox = dogBox; // 错误,Box<Dog> 不能赋值给 Box<Animal>
dogBox = animalBox; // 错误,Box<Animal> 不能赋值给 Box<Dog>
这就是不变的体现。TypeScript 要求 Box<Animal>
和 Box<Dog>
必须是完全一致的类型,任何尝试在这些类型之间进行赋值都会导致类型错误。
不变是一种型变规则,要求类型在所有上下文中必须严格匹配。比如我想要一只鸭子,你不能给我一个碗对吧,碗又不能吃。
总结
本文详细介绍了 TypeScript 类型系统中的四种型变概念:协变、逆变、双向协变 和 不变,以及它们在类型安全和灵活性方面的作用。
-
协变:允许子类型赋值给父类型,常见于函数的返回值和数组类型。协变使得类型系统更加灵活,例如,
List<Dog>
可以在需要List<Animal>
的地方使用。 -
逆变:允许父类型赋值给子类型,常见于函数的参数类型。逆变确保了类型系统的灵活性和安全性,比如将处理
Animal
的函数赋值给处理Dog
的函数。 -
双向协变:一种特殊情况,允许函数参数类型既可以协变也可以逆变,尽管这可能引发类型安全问题。双向协变在 TypeScript 中存在主要是为了保持与 JavaScript 的兼容性。
-
不变:要求类型在所有上下文中严格匹配,不能在子类型和父类型之间相互替换。这种严格的类型检查确保了类型系统的安全性,但也减少了灵活性。
理解这些型变概念能够帮助开发者在 TypeScript 中编写既灵活又安全的代码,尤其是在处理复杂的类型关系和函数参数时。它们为我们提供了一个平衡类型安全性和代码灵活性的工具。
转载自:https://juejin.cn/post/7402248021093007423