TypeScript 协变与逆变:你的泛型组件 Props 为什么总是类型报错?
上周封装一个通用列表组件,Props 里有个 onSelect 回调,类型大概长这样:
ts
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 的子类型。
ts
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)------方向不变:
ts
// Dog 是 Animal 的子类型
// → Dog[] 也是 Animal[] 的子类型(方向一致)
let dogs: Dog[] = [{ name: '旺财', breed: '柴犬' }]
let animals: Animal[] = dogs // ✅ 协变:子类型数组 → 父类型数组
逆变(Contravariant)------方向反了:
ts
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 明明是子类型啊,怎么函数这里反过来了?
为什么函数参数天然逆变
想一下实际调用场景:
ts
// 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>:
ts
interface ListProps<T> {
items: T[] // T 在输出位置 → 协变
onSelect: (item: T) => void // T 在函数参数位置 → 逆变
}
同一个泛型参数 T,在 items 里是协变的,在 onSelect 的参数里是逆变的。这就导致 T 处于一个既要协变又要逆变 的位置------术语叫不变(Invariant)。
实际后果:
ts
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 约束代替直接传递
ts
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 特有的属性。有时候能接受,有时候不行。
方案二:分离读和写的泛型
ts
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>
这个方案灵活,但两个泛型参数用起来心智负担大。组件泛型参数一多,调用方看着就头疼。
方案三:我个人更倾向的方式
实际项目里我用得最多的是这种------回调用泛型函数签名:
ts
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)------既协变又逆变都允许。
ts
// 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:
ts
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> 的协变位置。逆变套协变,结果还是逆变。协变套协变还是协变,逆变套逆变反而变成协变------跟负负得正一个道理。
ts
// 型变的组合规则:
// 协变 × 协变 = 协变 (正 × 正 = 正)
// 协变 × 逆变 = 逆变 (正 × 负 = 负)
// 逆变 × 逆变 = 协变 (负 × 负 = 正)
实际写组件时不需要时刻想着这个公式。但如果碰到类型报错死活想不通,把泛型参数在每一层的位置标出来,按这个规则推一遍,基本就清楚了。
来个实际踩坑场景:
ts
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 的子类型------大概率是你碰到逆变了,检查一下这个类型是不是在函数参数位置
实在搞不定:
ts
// 最终手段:用 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 下的行为居然不一样。官方说是为了兼容性,但这个设计确实容易让人踩坑。