首页 / TypeScript 入门教程 / 类型兼容性

TypeScript 入门教程

类型兼容性

本教程共 80 篇 · 第 53 篇 · 更新于 2026-08-10 · 约 12 分钟阅读

TypeScriptTypeScript 入门教程类型兼容性结构化类型Structural Typing协变与逆变

本节目标:理解 TypeScript 的类型兼容性规则——它不是看名字(名义类型),而是看结构(结构化类型)。掌握对象、函数、类、枚举的兼容性判断逻辑。

结构化类型 vs 名义类型

大多数静态类型语言(Java、C#)使用名义类型:两个类型要兼容,必须通过 extendsimplements 显式声明继承关系。类型名本身决定了兼容性。

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 只需要 xyPoint3D 都有,所以通过。反过来 Point3D 需要 zPoint2D 没有,所以拒绝。

这符合 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 的影响

privateprotected 成员会打破结构化类型的规则:如果目标类型包含一个 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 要求同源类
  • 泛型:类型参数没用上则兼容,用上了就按结构判断