TypeScript 协变与逆变:你的泛型组件 Props 为什么总是类型报错?

0 阅读1分钟

TypeScript 协变与逆变:你的泛型组件 Props 为什么总是类型报错?

上周封装一个通用列表组件,Props 里有个 onSelect 回调,类型大概长这样:

interface ListProps<T> {
  items: T[]
  onSelect: (item: T) => void
}

看着没毛病吧?结果一传具体类型就炸了——Type '(item: Dog) => void' is not assignable to type '(item: Animal) => void'

改了半天,越改越乱。后来才搞明白,这压根不是泛型的问题,是函数参数的逆变在搞事。

先把协变和逆变说人话

这俩词听着唬人,其实就一句话:父子类型在"容器"里的方向问题

假设 Dog extends Animal,那 Dog 是 Animal 的子类型。

interface Animal { name: string }
interface Dog extends Animal { breed: string }

let animal: Animal = { name: '旺财' }
let dog: Dog = { name: '旺财', breed: '柴犬' }

animal = dog // ✅ 子类型赋值给父类型,没问题
// dog = animal // ❌ 反过来不行,animal 上没有 breed

这是最基本的子类型赋值,没啥好说的。问题出在把类型塞进容器之后,方向可能会反。

协变(Covariant)——方向不变:

// Dog 是 Animal 的子类型
// → Dog[] 也是 Animal[] 的子类型(方向一致)
let dogs: Dog[] = [{ name: '旺财', breed: '柴犬' }]
let animals: Animal[] = dogs // ✅ 协变:子类型数组 → 父类型数组

逆变(Contravariant)——方向反了:

type Handler<T> = (arg: T) => void

let handleAnimal: Handler<Animal> = (a) => console.log(a.name)
let handleDog: Handler<Dog> = (d) => console.log(d.breed)

// 注意这里!方向反过来了
handleDog = handleAnimal // ✅ 父类型的 handler 赋值给子类型的 handler
// handleAnimal = handleDog // ❌ 反过来不行

等等,为什么 Handler<Animal> 反而能赋值给 Handler<Dog>?Dog 明明是子类型啊,怎么函数这里反过来了?

为什么函数参数天然逆变

想一下实际调用场景:

// handleDog 的调用方会传入一个 Dog
handleDog({ name: '旺财', breed: '柴犬' })

// 如果 handleDog 的实际实现是 handleAnimal:
// (a) => console.log(a.name)
// 收到一个 Dog,只用了 name → 完全没问题

// 反过来,如果 handleAnimal 的实际实现是 handleDog:
// (d) => console.log(d.breed)
// 收到一个普通 Animal,没有 breed → 运行时爆炸

所以函数参数是逆变的:你承诺能处理子类型,那实际实现必须至少能处理父类型。处理能力越宽泛,才越安全。

用个不太严谨但好记的比喻:你招了个岗位说要"能修柴犬的兽医",来了个"什么动物都能修的全科兽医"——没问题。反过来,岗位要"全科兽医",来了个"只会修柴犬的"——不行。

回到那个组件:问题出在哪

回到开头的 ListProps<T>

interface ListProps<T> {
  items: T[]          // T 在输出位置 → 协变
  onSelect: (item: T) => void  // T 在函数参数位置 → 逆变
}

同一个泛型参数 T,在 items 里是协变的,在 onSelect 的参数里是逆变的。这就导致 T 处于一个既要协变又要逆变的位置——术语叫不变(Invariant)

实际后果:

function renderList<T>(props: ListProps<T>) { /* ... */ }

const dogList: ListProps<Dog> = {
  items: [{ name: '旺财', breed: '柴犬' }],
  onSelect: (dog) => console.log(dog.breed)
}

// 想把 ListProps<Dog> 当 ListProps<Animal> 用?
// 不行。因为 T 既协变又逆变,类型锁死了
const animalList: ListProps<Animal> = dogList // ❌ Type error

这在封装通用组件时特别烦。你想让组件接受各种子类型的 Props,但类型系统不让。

实战解法:拆开读写位置

核心思路:别让同一个泛型参数同时出现在协变和逆变位置

方案一:用 extends 约束代替直接传递

interface ListProps<T extends Animal> {
  items: T[]
  // 回调参数放宽到 Animal,不跟 T 绑定
  onSelect: (item: Animal) => void
}

// 现在可以这样用
function DogList() {
  const props: ListProps<Dog> = {
    items: [{ name: '旺财', breed: '柴犬' }],
    onSelect: (animal) => console.log(animal.name) // 只能访问 Animal 的属性
  }
}

缺点很明显:onSelect 里拿不到 Dog 特有的属性。有时候能接受,有时候不行。

方案二:分离读和写的泛型

interface ListProps<TItem, TSelect = TItem> {
  items: TItem[]                    // TItem 只在协变位置
  onSelect: (item: TSelect) => void // TSelect 只在逆变位置
}

// 精确版:读写都是 Dog
type DogListExact = ListProps<Dog, Dog>

// 宽松版:读 Dog,回调接受 Animal 就行
type DogListLoose = ListProps<Dog, Animal>

这个方案灵活,但两个泛型参数用起来心智负担大。组件泛型参数一多,调用方看着就头疼。

方案三:我个人更倾向的方式

实际项目里我用得最多的是这种——回调用泛型函数签名

interface ListProps<T> {
  items: T[]
  onSelect: <U extends T>(item: U) => void  // 回调本身是泛型的
}

// 或者更常见的做法:直接用 readonly 把数组锁住
interface ListProps<T> {
  items: readonly T[]  // readonly → 去掉数组的"写"能力 → 纯协变
  onSelect: (item: T) => void
  renderItem: (item: T) => React.ReactNode
}

第二种写法虽然没完全解决逆变问题,但 readonly 至少在数组层面消除了一些不安全的操作。真实 React 组件里,items 基本不会在组件内部被修改,加 readonly 是好习惯。

strictFunctionTypes 这个坑必须提

TypeScript 2.6 引入了 strictFunctionTypes,开了之后函数参数才是严格逆变的。没开的话,函数参数是双变的(Bivariant)——既协变又逆变都允许。

// strictFunctionTypes: false(默认在非 strict 模式下)
handleAnimal = handleDog // ✅ 不报错,但运行时可能炸
handleDog = handleAnimal // ✅ 这个本来就是安全的

// strictFunctionTypes: true(推荐)
handleAnimal = handleDog // ❌ 正确地报错了
handleDog = handleAnimal // ✅

之前接手一个老项目,strict 没全开,一堆回调类型赋值都没报错。上线后各种 Cannot read property of undefined,查了半天才发现是函数参数类型不安全赋值导致的。后来开了 strict 一编译,好家伙,200 多个类型错误。

所以新项目一定开 strict。老项目迁移的话,可以先单独开 strictFunctionTypes,影响范围相对可控。

复杂场景:嵌套泛型组件的 Props 传递

真实业务里,组件经常是嵌套的。比如一个 Table 里面用了 Column

interface ColumnProps<T> {
  dataIndex: keyof T
  render: (value: T[keyof T], record: T) => React.ReactNode
  // render 的两个参数都是逆变位置
  // dataIndex 是... 额,keyof T 比较特殊,先不展开
}

interface TableProps<T> {
  data: readonly T[]
  columns: ColumnProps<T>[]
  onRowClick?: (record: T) => void
}

这里 ColumnProps<T> 里的 render 参数是逆变的,而 ColumnProps<T>[] 整体又被放在 TableProps<T> 的协变位置。逆变套协变,结果还是逆变。协变套协变还是协变,逆变套逆变反而变成协变——跟负负得正一个道理。

// 型变的组合规则:
// 协变 × 协变 = 协变  (正 × 正 = 正)
// 协变 × 逆变 = 逆变  (正 × 负 = 负)
// 逆变 × 逆变 = 协变  (负 × 负 = 正)

实际写组件时不需要时刻想着这个公式。但如果碰到类型报错死活想不通,把泛型参数在每一层的位置标出来,按这个规则推一遍,基本就清楚了。

来个实际踩坑场景:

interface FormFieldProps<T> {
  value: T                          // 协变
  onChange: (newValue: T) => void   // 逆变
  validate: (value: T) => string | null  // 逆变
}

// 想做一个高阶组件,给 FormField 加默认校验
function withValidation<T>(
  WrappedField: React.ComponentType<FormFieldProps<T>>,
  defaultValidator: (value: T) => string | null
) {
  // 这里 WrappedField 的泛型参数 T 在 ComponentType 的参数位置
  // ComponentType<P> 中 P 是逆变的(props 是函数参数)
  // 所以 T 经过两层:逆变(ComponentType的P) × 逆变(onChange的参数) = 协变
  // 也就是说对于 onChange 这条链路,T 最终是协变的
  // 但对于 value 这条链路:逆变(ComponentType的P) × 协变(value) = 逆变
  // T 同时协变和逆变 → 不变
  // 所以这个 HOC 的 T 是不变的,不能传子类型替代
  return WrappedField
}

看到没?HOC 里泛型的型变分析能绕晕人。我的经验是:如果高阶组件的类型推导搞得太复杂,换成 hooks 或者 render props 往往更好处理。不是说 HOC 不能用,而是 HOC 天然多一层类型嵌套,在 TS 里确实更容易出问题。

几个判断型变的快速技巧

写了这么多,分享几个我日常用的快速判断方法:

看位置:

  • 函数返回值、属性值、Promise 的 resolve 值 → 协变位置(输出)
  • 函数参数、回调参数 → 逆变位置(输入)

看 readonly:

  • readonly T[]T[] 在型变上更友好,因为去掉了写入操作
  • Readonly<Record<string, T>> 同理

看报错:

  • 如果报错是 Type 'A' is not assignable to type 'B',而你觉得 A 明明是 B 的子类型——大概率是你碰到逆变了,检查一下这个类型是不是在函数参数位置

实在搞不定:

// 最终手段:用 type assertion 或 as unknown as
// 但要确保你真的理解为什么类型不兼容
// 别无脑 as any,那跟写 JavaScript 有什么区别
const handler = dogHandler as unknown as Handler<Animal>

聊到这

协变逆变不是什么高深的类型体操。说到底就一件事:类型安全在"输入"和"输出"两个方向上的要求是相反的。输出可以更具体(协变),输入必须更宽泛(逆变)。

设计泛型组件 Props 的时候,把每个泛型参数的位置标一下,哪些是输出、哪些是输入,型变关系自然就清楚了。碰到实在不兼容的情况,优先考虑拆分泛型参数或者调整 API 设计,而不是上来就 as any

有一点我到现在也没想通:TypeScript 对方法(method)的类型检查默认是双变的,而对函数属性(function property)才是逆变的。比如 interface Foo { bar(x: T): void }interface Foo { bar: (x: T) => void }strictFunctionTypes 下的行为居然不一样。官方说是为了兼容性,但这个设计确实容易让人踩坑。