不知道你在看到这个标题的时候会不会想:这都是什么狗屁玩意?
至少我第一次看到这三个词的时候是这么想的。
这三个概念在计算机科学中和类型系统有关,主要应用在泛型参数中父类和子类之间的赋值约束之间。
如果你看不懂上一句话说什么,很正常,光说这个我也看不懂,所以我们还是用例子来解释吧。
为什么会接触到这个概念
最近在用 zustand 写一些东西,但是看到 zustand 里对于 TypeScript 的支持是这么写的:

如果使用 JS 的话,直接
create((set) => ({...}));
create(immer((set) => ({...}))); // 使用 immer 中间件
就可以,但是使用 TS 的话,就得写成这样:
create<State>()((set) => ({...}));
create<State>()(immer((set) => ({...}))); // 使用 immer 中间件
这是何苦?明明传一个参数的事情为啥要大费周章弄个 curry 化?
如果你不知道 curry 化是什么意思:
可以简单理解为,对一个函数做 curry 化,就是把一个函数改为“一次调用就是塞一个参数,塞完就运行”的形式。
比如说 add = (a, b) => a + b,本来 add(1, 2) === 3,
curry 化后的 add 为 curried_add,那么 curried_add(1)(2) === 3,
每一次调用相当于塞一个参数进去,塞满参数的那一次调用会执行并且返回结果。


翻译:
太长不看版:因为 T 是不变的(invariant)。在这里 T 既是协变的(covariant)又是逆变的(contravariant),所以它是不变的。
、、这是人类语言吗?
于是在请教过 Gemini 老师,Kimi 老师之后,我总算对这几个概念有大概的理解了。
协变和逆变
协变,英文叫 covariant;逆变,英文叫 contravariant。
那么什么时候该用上这个不像人话的词呢?
这里做一个比方。
比如这里有一个父类和子类,父类就叫 Animal 吧,子类叫 Cat。
在泛型中,如果参数 T 子类可以代替父类,那么 T 就是协变的。比如 Array<T> 是一个泛型,Array<Cat> 可以赋值给 Array<Animal>,所以 Array<T> 中的 T 是协变的。
type Get<T> = () => T;
let fn: Get<Animal>;
fn = (cat: Cat) => cat; // 相当于把 Get<Cat> 赋值给 Get<Animal>
// Get<Animal> 保证返回一个 Animal,而 Cat 也是一种 Animal,所以直觉上也是符合的
反过来,如果参数 T 父类可以代替子类,那么 T 就是逆变的。比如 Handler<T> 是一个泛型,写成代码是这样
type Handler<T> = (value: T) => void;
let fn: Handler<Cat>;
fn = (animal: Animal) => console.log(animal); // 相当于把 Handler<Animal> 赋值给了 Handler<Cat> 类型
// Handler<Cat> 可以处理 Cat 类型
// Handler<Animal> 可以处理 Animal 类型
// 因为 Cat 也是一种 Animal,既然可以处理 Animal 那肯定也可以处理 Cat,直觉上也是合理的
所以 Handler<T> 的 T 是逆变的。
不变
不变,英文叫 invariant。
在泛型中,如果参数 T 子类既代替父类,又不可以父类代替子类,T 只能是 T 自己,那么 T 就是不变。
比如说有这么一个类型 Identify<T>
type Identity<T> = (value: T) => T;
let animalIdentity: Identity<Animal> = (animal: Animal) => animal;
let catIdentity: Identity<Cat> = (cat: Cat) => cat;
在这里,Identity<Animal> 和 Identity<Cat> 都是不变的(Invariant)。animalIdentity 不能赋值给 catIdentity,catIdentity 也不能赋值给 animalIdentity。
如何分辨 T 是协变,逆变还是不变的?
T 可能出现在很多地方,要根据 T 出现的地方来判断是协变还是逆变。
AI 和百科都会告诉你:
如果 T 是生产出来的类型,那 T 就是协变的。
如果 T 是被消费的类型,那 T 就是逆变的。
如果 T 既是生产出来的类型又是被消费的类型,那 T 就是不变的。
比如,() => T 里 T 是生产出来的类型(返回值),所以 T 是协变的。
(value: T) => void 里 T 是被消费的类型(参数),所以 T 是逆变的。
(value: T) => T 里 T 既是生产者又是消费者,所以 T 是不变的。
是不是很简单呢?那接下来看看这个里面 T 是哪种吧!
(action: (arg: T) => void) => T;
相信聪明的你一定已经看出来了,这里的 T 是协变的!拍手拍手拍手!
什么你说你看不出来?没关系!我立马教你看出来!
判断方法
看函数签名。
- 所有的 T 一开始都是协变的
- 如果右边有一个箭头,则变成逆变,再遇到一个箭头则变成协变,以此类推。(本质像正负数乘法,负负得正)
- 如果所有位置的 T 都是协变的,则这个类型的 T 就是协变的。如果所有位置的 T 都是逆变的,则这个类型的 T 是逆变的。如果有些位置是协变有些位置是逆变,那么这个类型的 T 是不变的。
接下来分析上面的例子。
(arg: T) 里的 T 一开始是协变的,右边遇到一个箭头变逆变,再遇到一个箭头变协变,所以这个 T 是协变的。
(action: (arg: T) => void) => T; 最右边的 T 一开始是协变的,右边没有箭头,所以这个 T 是协变的。
所以,(action: (arg: T) => void) => T 这个类型的 T 是协变的。
type Co<T> = (action: (arg: T) => void) => T;
let animal: Co<Animal> = cat as Co<Cat>; // 这样是 ok 的
背后的意义
这个方法很好理解,但是协变逆变背后的意义是什么呢?
逆变
class Animal {
constructor(public name: string = 'Animal') { }
}
class Cat extends Animal {
constructor(public character: string = 'naughty') {
super('Cat');
}
}
const cat = new Cat();
const animal = new Animal();
// 容易判断出 SkillFor<T> 的 T 是逆变的
type SkillFor<T> = (creature: T) => void;
// 这是针对猫的技能,可以判断猫的类型
const distinguishCatCharacter = (cat: Cat) => {
console.log('This cat\'s character is', cat.character);
}
// 这是对所有动物都有用的技能,可以说出动物的名字
const callAnimalName = (animal: Animal) => {
console.log('This living animal\'s name is', animal.name);
}
// catHandler 是一个函数,将会处理一只猫
let catHandler: SkillFor<Cat>;
// 这个赋值是有效的,符合逆变规则,父类可以赋值给子类
// 既然 callAnimalName 对所有动物都有用,那自然也对 Cat 有用
// 所以可以用 callAnimalName 来处理一只猫
catHandler = callAnimalName;
catHandler(cat); // This living animal's name is cat
协变
简单的协变很复合直觉,所以来个复杂一点的。
// JobNeedHandlerOf<T> 是一个工作岗位,这个岗位需要一个能处理 T 的技能
// 根据上面的方法容易看出 JobNeedHandlerOf<T> 的 T 是协变的,但是该怎么理解呢?
type JobNeedHandlerOf<T> = (handler: (arg: T) => void) => void;
// 一个需要应对猫的岗位
const jobNeedHandlerOfCat: JobNeedHandlerOf<Cat> = (catHandler: (cat: Cat) => void) => {
console.log('Need to handle Cat:');
catHandler(cat);
};
// 一个需要应对所有动物的岗位
const jobNeedHandlerOfAnimal: JobNeedHandlerOf<Animal> = (animalHandler: (animal: Animal) => void) => {
console.log('Need to handle Animal:');
animalHandler(animal);
}
// jobHandlingAnimal 一个需要应对动物的岗位
let jobHandlingAnimal: JobNeedHandlerOf<Animal>;
// 一个需要应对猫的岗位自然也是一个需要应对动物的岗位
// 因为猫也是一种动物
jobHandlingAnimal = jobNeedHandlerOfCat;
// 一个需要应对动物的岗位自然需要应对动物的方法
jobHandlingAnimal(callAnimalName);
// Need to handle Cat:
// This living animal's name is Cat
jobHandlingAnimal(distinguishCatCharacter); // 这一句会报错
// 如果没有报错,那么 tsconfig.json 可能没有 "strict": true,
// 没有这一项的 TypeScript 对于参数的约束并不是逆变
// 继续分析上面那一行
// JobNeedHandlerOf<T> 是协变的,但是它的参数
// (arg: T) => void 是逆变的
// (arg: Cat) => void 不能赋值给 (arg: Animal) => void
let jobHandlingCat: JobNeedHandlerOf<Cat>;
jobHandlingCat = jobNeedHandlerOfCat;
// 同理,(arg: T) => void 里 T 是逆变的
// (arg: Animal) => void 可以赋值给 (arg: Cat) => void
jobHandlingCat(callAnimalName);
// Need to handle Cat:
// This living animal's name is Cat
回到 zustand 的例子
既然我们已经搞懂了协变、逆变和不变是什么东西,那么和我们一开始遇到的问题有什么关系呢?
gemini老师如是说
您提出了一个非常好的问题,这正好触及了 TypeScript 类型推断机制中一个非常精妙且关键的点。您的逻辑是完全合理的:如果返回值是 string
,那么 T
就应该是 string
,因此参数 t
也应该是 string
。
然而,TypeScript 的类型推断并不是单向的。它不是仅仅根据返回值来确定 T
,然后将这个结果反向应用到参数上。相反,编译器会从所有使用了 T
的地方收集“候选类型”(Type Candidates),然后试图在这些候选类型中找到一个“最佳通用类型”(Best Common Type)。
让我们扮演一次 TypeScript 编译器,来分析 createFoo((_) => 'hello')
这个调用。
编译器的“思考”过程
目标: 确定泛型 T
的具体类型。
依据:
- 泛型约束:
T
必须满足<T>(f: (t: T) => T) => T
这个签名。 - 传入的实参:
(_) => 'hello'
。
步骤 1:收集 T
的候选类型
编译器会检查所有 T
出现的位置,并从传入的函数 (_) => 'hello'
中寻找对应的线索。
- 线索来源 A:参数位置
- 签名要求:参数是
t: T
。 - 传入的实参:参数是
_
。 - 分析:这个
_
没有被提供任何类型信息。对于编译器来说,这个参数可以是任何类型 (any
/unknown
),因为它被忽略了。从这个位置,编译器无法为T
找到一个明确的候选类型。它从参数这里得到的关于T
的信息是 “无限制” 或 “未知”。所以,从参数位置,T
的候选类型是unknown
。
- 签名要求:参数是
- 线索来源 B:返回值位置
- 签名要求:返回值是
T
。 - 传入的实参:返回值是
'hello'
。 - 分析:表达式
'hello'
的类型是string
。这是一个非常强烈的信号。从这个位置,编译器为T
找到了一个明确的候选类型:string
。
- 签名要求:返回值是
步骤 2:统一所有候选类型
现在,编译器手上有两个从不同来源收集到的关于 T
的候选类型:
- 来自参数位置的候选:
unknown
- 来自返回值的候选:
string
签名的核心要求是 (t: T) => T
,这意味着参数的类型和返回值的类型必须是同一个 T
。因此,编译器必须找到一个单一的类型,这个类型既能满足参数候选 unknown
,也能满足返回值候选 string
。
什么是 unknown
和 string
的“最佳通用类型”?
在 TypeScript 的类型系统中,unknown
是所有类型的“祖先”(Top Type)。任何类型都可以被赋给 unknown
,但 unknown
不能被赋给除了 any
和它自己以外的任何类型。
当编译器试图统一 unknown
和 string
时,它会寻找一个能同时容纳这两者的最具体的类型。这个类型就是 unknown
。string
是 unknown
的子类型,但反过来不成立。为了保证类型安全,编译器只能选择更宽泛、更通用的那个,也就是 unknown
。
结论
因为参数 _
没有提供类型信息,导致该位置的候选类型为 unknown
。这就“污染”了整个类型推断过程。即使返回值给出了 string
的强提示,编译器也无法忽略参数位置的模糊性。为了统一 unknown
和 string
这两个候选,它只能选择 unknown
作为最终推断出的 T
的类型。
打个比方
把 (t: T) => T
想象成一个严格的合同模板: “甲方(类型 T
)和乙方(类型 T
)必须是同一个实体。”
现在你提供了一份填写好的合同: “甲方是一个不在乎身份的匿名人士 (_
),乙方是张三 ('hello'
)。”
法官(编译器)看到这份合同就会说:“等等,合同要求甲乙双方是同一个人。但你提供的这份文件里,乙方明确是‘张三’,而甲方身份不明。我无法确认这个‘匿名人士’就是‘张三’。由于甲方身份未知(unknown
),我不能批准这份合同,我只能认定当事人的共同身份是‘未知’(unknown
)。”
这就是为什么编译器无法安全地断定 T
就是 string
。因为你没有在参数这边提供足够的证据来支持这个断言。一旦你像下面这样提供了证据,问题就解决了:
TypeScript
// 你明确告诉编译器:“甲方是‘张三’,乙方也是‘张三’。” // 编译器:“完美,甲乙双方都是 string,合同成立!” const x = createFoo((t: string) => 'hello'); // x 是 string