类型兼容性
本教程共 80 篇 · 第 53 篇 · 更新于 2026-08-10 · 约 12 分钟阅读
本节目标:理解 TypeScript 的类型兼容性规则——它不是看名字(名义类型),而是看结构(结构化类型)。掌握对象、函数、类、枚举的兼容性判断逻辑。
结构化类型 vs 名义类型
大多数静态类型语言(Java、C#)使用名义类型:两个类型要兼容,必须通过 extends 或 implements 显式声明继承关系。类型名本身决定了兼容性。
TypeScript 不同,它使用结构化类型(structural typing):类型的兼容性由成员结构决定,不看名字。只要结构上匹配,两个完全无关的类型也可以互相赋值。
interface Named {
name: string;
}
class Person {
name: string;
age: number;
}
let n: Named;
n = new Person(); // ✅ Person 有 name 属性,结构兼容
Person 类没有 implements Named,但它有 name: string 属性,所以可以赋给 Named 类型。这就是结构化类型的核心思想:鸭子类型——走起来像鸭子、叫起来像鸭子,那它就是鸭子。
对象兼容性:多属性的可以赋给少属性的
这是新手最容易搞反的一条规则:
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
let p2: Point2D;
let p3: Point3D = { x: 0, y: 10, z: 20 };
// 多属性的赋给少属性的——✅ OK
p2 = p3;
// 少属性的赋给多属性的——❌ Error
// p3 = p2; // 缺少 z
为什么?检查方向是目标类型检查源类型的每个属性。Point2D 只需要 x 和 y,Point3D 都有,所以通过。反过来 Point3D 需要 z,Point2D 没有,所以拒绝。
这符合 JavaScript 的实际使用模式——函数只关心它需要的属性,多余的属性不影响函数正常工作:
function printPoint(p: Point2D) {
console.log(`(${p.x}, ${p.y})`);
}
printPoint(p3); // ✅ 多余的 z 被无视,完全合理
函数兼容性
函数之间的兼容性有三个维度,规则各有不同。
参数数量:少参数的可以赋给多参数的
let fn1 = (a: number) => 0;
let fn2 = (a: number, b: string) => 0;
fn2 = fn1; // ✅ fn1 忽略多余参数是安全的
// fn1 = fn2; // ❌ fn2 多一个必需参数,fn1 的位置无法满足
JavaScript 里忽略多余的参数是家常便饭,比如 Array.forEach 的回调有 3 个参数,但你只写 1 个照样用。
返回值:协变(子类型 → 父类型)
let fnA = (): Point2D => ({ x: 0, y: 0 });
let fnB = (): Point3D => ({ x: 0, y: 0, z: 0 });
fnA = fnB; // ✅ fnB 返回更多属性,满足 fnA 的要求
// fnB = fnA; // ❌ fnA 返回的缺少 z
参数类型:双向协变
TypeScript 的函数参数默认是双向协变(bivariant)的——子类型可以赋给父类型,父类型也可以赋给子类型:
interface Event { timestamp: number }
interface MouseEvent extends Event { x: number; y: number }
type Handler = (e: Event) => void;
// 下面这行默认是允许的(双向协变)
const handler: Handler = (e: MouseEvent) => console.log(e.x, e.y);
这从类型安全角度看”不够严谨”,但它支持了 JavaScript 中极其常见的事件处理模式。如果不允许双向协变,你每次写事件处理函数都得手动断言类型。
启用
strictFunctionTypes: true(TypeScript 7.0 默认开启)后,函数参数改为逆变检查——更安全。但在实际项目里,这个配置会影响回调函数类型推断,可能需要少量调整。
枚举兼容性
枚举和数字互相兼容:
enum Status { Ready, Waiting }
enum Color { Red, Blue, Green }
let s: Status = Status.Ready;
s = 1; // ✅ 数字可以赋给枚举
// s = Color.Red; // ❌ 不同的枚举类型不兼容
实际上枚举值在运行时就是数字,所以 TypeScript 允许这种赋值。但不同枚举之间不能混用——虽然它们底层都是数字,语义上是两种东西。
类的兼容性
类之间的比较只看实例成员,不看构造函数和静态成员:
class Animal {
feet: number;
constructor(name: string, numFeet: number) {
this.feet = numFeet;
}
}
class Size {
feet: number;
constructor(numFeet: number) {
this.feet = numFeet;
}
}
let a: Animal;
let s: Size;
a = s; // ✅ 实例结构一样(都有 feet: number)
s = a; // ✅
连构造函数参数都不同——但 TypeScript 不在乎,因为比较的是实例部分。
private 和 protected 的影响
private 和 protected 成员会打破结构化类型的规则:如果目标类型包含一个 private 成员,那源类型必须来自同一个类:
class A {
private secret: string = "a";
name: string = "A";
}
class B {
private secret: string = "b";
name: string = "B";
}
let a: A = new A();
// a = new B(); // ❌ B 的 private secret 和 A 的不在同一个类声明中
即使两个类的结构完全一样,只要有 private 成员,它们就不能互赋——除非 B extends A。这个规则保证了封装的完整性:私有成员是”自己类独有的”。
泛型兼容性
泛型本身是否兼容,取决于类型参数是否被实际使用:
interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;
x = y; // ✅ T 没用上,结构一样
interface NotEmpty<T> {
data: T;
}
let a: NotEmpty<number>;
let b: NotEmpty<string>;
// a = b; // ❌ data 类型不同了
小结
- TypeScript 使用结构化类型,名称不重要,结构决定兼容性
- 对象:属性多的可以赋给属性少的(源 ≥ 目标)
- 函数:参数数少的可赋给多的;返回值协变;参数默认双向协变
- 枚举:和数字兼容,但不同枚举间不兼容
- 类:只比较实例成员;
private/protected要求同源类 - 泛型:类型参数没用上则兼容,用上了就按结构判断