关于TS类型系统中的协变与逆变

在 TypeScript 类型系统中,类型兼容性是核心支柱,而协变逆变双变不变 则是描述类型转换规则的四大概念。它们决定了不同类型的集合(如数组、函数、泛型)之间如何安全地兼容,直接影响代码的类型安全性。但是之前一直很难理解协变与逆变,所以写本文记录一下。

一、类型兼容性

首先明确子类型父类型 的判定

子类型

TypeScript 中,子类型的核心判定依据是"赋值兼容性" :若类型 B 的所有可能值都能安全地赋值给类型 A 的变量,则 BA 的子类型(记为 B extends A)。通俗说,子类型的范围是父类型范围的"子集"

不同类型的子类型关系示例:

  1. 对象类型 :结构兼容即子类型
    B 包含 A 所有属性且类型匹配,则 BA 的子类型(B 的范围是 A 的子集)。
ts 复制代码
type Animal = { name: string }; // 父类型:所有含 name 的对象
type Dog = { name: string; bark: () => void }; // 子类型:范围更小(仅含 bark 的 Animal)
const dog: Dog = { name: "旺财", bark: () => {} };
const animal: Animal = dog; // 合法:Dog 是 Animal 的子集
  1. 联合类型 :范围收窄即子类型
    B 的所有成员都属于 A 的成员,则 BA 的子类型(B 范围更小)。
ts 复制代码
type NumOrStr = number | string; // 父类型:范围 {number, string}
type OnlyNum = number; // 子类型:范围 {number}(NumOrStr 的子集)
const num: OnlyNum = 123;
const numOrStr: NumOrStr = num; // 合法:number 是 number|string 的子集
  1. 基础类型 :字面量是基础类型的子类型
    字面量类型的范围是基础类型的子集(如 123number 的子集)。
ts 复制代码
type Age = number;
type SpecificAge = 18; // 子类型:范围仅 {18}
const age: Age = 18;
const specificAge: SpecificAge = 18;
const age2: Age = specificAge; // 合法:18 是 number 的子集

协变与逆变这两个术语源于数学中的范畴论(Category Theory) ,后来被计算机科学引入类型系统领域。物理学中也有类似的,简单来说就是"一个量随另一个量的变化,如果一致就是协变,否则就是逆变"(不太严谨),在类型系统中就是某个类型集合兼容性随某些类型变化是怎么样的?比如数组类型随其元素类型变化后的类型兼容性如何变化?函数类型随其参数类型或返回值类型变化后的类型兼容性如何?

二、协变:子类型关系的"正向传递"

协变 指:当 BA 的子类型时,B 的"容器类型"也是 A 的"容器类型"的子类型(即 F<B> extends F<A>)。简单说,容器的子类型关系与原类型"同向"

典型场景:

  1. 数组的协变
    B extends A,则 B[] extends A[](子类型数组是父类型数组的子类型)。
ts 复制代码
const dogs: Dog[] = [dog];
const animals: Animal[] = dogs; // 合法:Dog[] 是 Animal[] 的子类型(协变)
    • 安全性:从 animals 读取元素时,总能获得 Animal 所需的 name 属性(安全)。
    • 隐患:向 animals 写入非 Dog 类型(如 { name: "猫" })会破坏 dogs 的类型一致性(TS 未禁止,历史遗留问题)。
  1. 函数返回值的协变
    若函数返回 B(子类型),则该函数是返回 A(父类型)的函数的子类型。
ts 复制代码
const getDog = (): Dog => ({ name: "旺财", bark: () => {} });
type GetAnimal = () => Animal;
const getAnimal: GetAnimal = getDog; // 合法:返回 Dog 的函数是返回 Animal 的函数的子类型
    • 安全性:调用 getAnimal 时,返回值至少包含 Animal 所需的 name(安全)。

三、逆变:子类型关系的"反向传递"

逆变 指:当 BA 的子类型时,A 的"函数类型"是 B 的"函数类型"的子类型(即 (a: A) => void extends (b: B) => void)。简单说,函数参数的子类型关系与原类型"反向"

典型场景:函数参数的逆变

ts 复制代码
// 接收父类型 Animal 的函数(仅依赖 name)
const feedAnimal = (animal: Animal) => console.log(`喂 ${animal.name}`);
// 接收子类型 Dog 的函数类型
type FeedDog = (dog: Dog) => void;

// 逆变:feedAnimal 可赋值给 FeedDog
const feedDog: FeedDog = feedAnimal; // 合法
  • 安全性:feedDog 接收 Dog 类型参数,而 feedAnimal 仅需 name 属性,Dog 必然包含(安全)。
  • 反例:若允许接收 Dog 的函数赋值给接收 Animal 的函数,会因参数"能力不足"报错:
ts 复制代码
const badFeedDog = (dog: Dog) => dog.bark(); // 依赖 Dog 的 bark 方法
const badFeedAnimal: (a: Animal) => void = badFeedDog; // ❌ TS 报错(不安全)

四、双变:子类型关系的"双向兼容"

双变 指:当 BA 的子类型时,F<A>F<B> 互相兼容(既可以 F<B> extends F<A>,也可以 F<A> extends F<B>)。这种"双向宽松"的兼容性可能破坏类型安全,TypeScript 中仅在特定场景下存在。

典型场景:方法参数的双变(历史遗留)

早期 TypeScript 中,对象方法的参数是双变的(函数参数是逆变,但方法参数例外):

ts 复制代码
interface Handler {
  handle: (a: Animal) => void;
}
interface DogHandler {
  handle: (d: Dog) => void;
}

// 双变:Handler 与 DogHandler 互相兼容(TS 2.6 前默认行为)
const handler: Handler = { handle: (a) => {} };
const dogHandler: DogHandler = handler; // 合法(类似逆变)
const handler2: Handler = dogHandler; // 合法(类似协变,实际不安全)
  • 风险:handler2.handle 可能接收非 Dog 类型(如猫),但 dogHandler.handle 可能依赖 bark 方法,导致错误。
  • 现状:通过 strictFunctionTypes: true(严格模式)可禁用双变,使方法参数遵循逆变(推荐开启)。

五、不变:子类型关系的"完全一致"

不变 指:仅当 AB 完全相同时,F<A>F<B> 才兼容(既不协变也不逆变)。这是最严格的兼容性规则,避免协变/逆变可能的安全隐患。

典型场景:普通泛型的不变

ts 复制代码
// 泛型容器(默认不变)
type Box<T> = { value: T };

type AnimalBox = Box<Animal>;
type DogBox = Box<Dog>;

const dogBox: DogBox = { value: dog };
const animalBox: AnimalBox = dogBox; // ❌ 错误:Box<Dog> 与 Box<Animal> 互不兼容
  • 原因:若允许 DogBox 赋值给 AnimalBox,可能向 animalBox 写入非 Dog 类型(如猫),破坏 dogBox.value 的类型一致性。

六、实际中的一些场景

1. 事件处理(依赖逆变保证安全)

ts 复制代码
type Event = { type: string };
type MouseEvent = Event & { x: number; y: number };

// 处理通用事件的函数(可用于鼠标事件回调)
const logEvent = (e: Event) => console.log(e.type);
const handleClick: (e: MouseEvent) => void = logEvent; // 合法(逆变)

2. 状态管理(泛型不变避免类型污染)

ts 复制代码
// 状态容器(不变泛型,确保状态类型严格一致)
type StateContainer<T> = {
  state: T;
  setState: (newState: T) => void;
};

type UserState = { name: string };
type AdminState = { name: string; role: string };

// 错误:AdminState 是 UserState 的子类型,但容器不变
const userContainer: StateContainer<UserState> = {
  state: { name: "用户" },
  setState: (s) => {},
};
const adminContainer: StateContainer<AdminState> = userContainer; // ❌ 错误

3. 开启严格模式(禁用双变,增强安全性)

tsconfig.json 中开启 strictFunctionTypes: true,强制方法参数遵循逆变:

ts 复制代码
{
  "compilerOptions": {
    "strictFunctionTypes": true
  }
}

七、对比

概念 子类型关系(B extends A 时) 典型场景 安全性
协变 F<B> extends F<A> 数组、函数返回值 读取安全,写入需谨慎
逆变 F<A> extends F<B> 函数参数 输入安全
双变 F<A> extends F<B>F<B> extends F<A> 方法参数(非严格模式) 安全性低,可能隐患
不变 F<A> extends F<B> 当且仅当 A = B 普通泛型容器 最安全,限制严格

函数类型的兼容性,随参数类型变化而逆变的方式来判定,而返回值类型使用协变的方式确定;而数组类型兼容性与元素类型是协变

相关推荐
Sheldon一蓑烟雨任平生3 小时前
10 分钟速通 TypeScript 核心
typescript·接口·类型断言·typescript 类型·联合类型·类型别名·对象类型
月下点灯4 小时前
🏮一眼就会🗂️大文件分片上传,白送前后端全套功法
javascript·typescript·node.js
fthux14 小时前
孩子的名字有救了
微信小程序·typescript·ai编程
常常不爱学习1 天前
Vue3 + TypeScript学习
开发语言·css·学习·typescript·html
AAA不会前端开发3 天前
TypeScript核心类型系统完全指南
前端·typescript
sen_shan3 天前
Vue3+Vite+TypeScript+Element Plus开发-27.表格页码自定义
前端·javascript·typescript
烛阴3 天前
为什么 `Promise.then` 总比 `setTimeout(..., 0)` 快?微任务的秘密
前端·javascript·typescript
披萨心肠5 天前
Typescript数组与元组类型
typescript·编程语言
一点七加一5 天前
Harmony鸿蒙开发0基础入门到精通Day11--TypeScript篇
前端·javascript·typescript